From 93a7d3d0b0f2dfd3ab5cde4a37688fdb07bca40f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 7 May 2026 23:23:04 -0400 Subject: [PATCH 1/8] ship: prepare lane for review --- apps/ade-cli/README.md | 2 + apps/ade-cli/src/adeRpcServer.test.ts | 108 ++++- apps/ade-cli/src/adeRpcServer.ts | 135 ++++++ apps/ade-cli/src/cli.test.ts | 52 +++ apps/ade-cli/src/cli.ts | 37 ++ apps/ade-cli/tsconfig.json | 2 +- .../src/main/services/pty/ptyService.ts | 4 +- .../services/sync/syncHostService.test.ts | 82 +++- .../src/main/services/sync/syncHostService.ts | 4 +- .../sync/syncRemoteCommandService.test.ts | 127 +++++- .../services/sync/syncRemoteCommandService.ts | 99 ++++ .../components/terminals/cliLaunch.ts | 359 +-------------- apps/desktop/src/renderer/lib/shell.ts | 195 +------- apps/desktop/src/shared/cliLaunch.ts | 358 +++++++++++++++ apps/desktop/src/shared/shell.ts | 187 ++++++++ apps/desktop/src/shared/types/sync.ts | 23 +- apps/ios/ADE.xcodeproj/project.pbxproj | 4 + apps/ios/ADE/Models/RemoteModels.swift | 6 + apps/ios/ADE/Services/SyncService.swift | 95 +++- apps/ios/ADE/Views/Lanes/LaneTypes.swift | 8 +- .../Work/WorkArtifactTerminalViews.swift | 84 ++-- .../ios/ADE/Views/Work/WorkModelCatalog.swift | 2 +- .../ADE/Views/Work/WorkNewChatScreen.swift | 248 ++++++++-- apps/ios/ADE/Views/Work/WorkPreviews.swift | 1 + .../ADE/Views/Work/WorkRootComponents.swift | 2 +- .../Views/Work/WorkRootScreen+Actions.swift | 37 +- apps/ios/ADE/Views/Work/WorkRootScreen.swift | 11 + .../Work/WorkStatusAndFormattingHelpers.swift | 18 +- .../Views/Work/WorkTerminalEmulatorView.swift | 423 ++++++++++++++++++ .../ADE/Views/Work/WorkTimelineHelpers.swift | 4 +- apps/ios/ADETests/ADETests.swift | 44 +- docs/ARCHITECTURE.md | 8 +- docs/features/sync-and-multi-device/README.md | 39 +- .../sync-and-multi-device/ios-companion.md | 43 +- .../sync-and-multi-device/remote-commands.md | 46 +- .../features/terminals-and-sessions/README.md | 60 ++- .../pty-and-processes.md | 4 +- .../terminals-and-sessions/ui-surfaces.md | 12 +- 38 files changed, 2249 insertions(+), 724 deletions(-) create mode 100644 apps/desktop/src/shared/cliLaunch.ts create mode 100644 apps/desktop/src/shared/shell.ts create mode 100644 apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 58a7ba394..93586faef 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -67,6 +67,8 @@ ade prs pipeline pr-id save --conflict-strategy rebase --no-early-merge-on-green ade run defs --text ade run start web --lane lane-id ade shell start --lane lane-id -- npm test +ade shell start-cli codex --lane lane-id --permission-mode edit --message "fix failing tests" +ade shell start --provider claude --lane lane-id --permission-mode default ade chat create --lane lane-id --model gpt-5.5 ade tests run --lane lane-id --suite unit --wait ade proof list --arg ownerKind=chat --arg ownerId=session-id diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 035a4a989..1886be6a5 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -277,7 +277,9 @@ function createRuntime() { }, ptyService: { create: vi.fn(async () => ({ ptyId: "pty-1", sessionId: "session-1" })), - dispose: vi.fn() + dispose: vi.fn(), + writeBySessionId: vi.fn(() => true), + enrichSessions: vi.fn((sessions: unknown[]) => sessions), }, testService: { run: vi.fn(async () => ({ id: "test-run-1", status: "running" })), @@ -1085,6 +1087,7 @@ describe("adeRpcServer", () => { "screenshot_environment", "record_environment", "run_tests", + "start_cli_session", "get_lane_status", "list_lanes", "commit_changes", @@ -1918,6 +1921,109 @@ describe("adeRpcServer", () => { expect(response.structuredContent.contextRef?.path).toBeNull(); }); + it("routes start_cli_session through shared provider launch helpers", async () => { + const fixture = createRuntime(); + fixture.runtime.sessionService.get.mockReturnValue({ + id: "session-1", + laneId: "lane-1", + ptyId: "pty-1", + tracked: true, + toolType: "codex", + title: "Codex", + status: "running", + resumeCommand: null, + resumeMetadata: null, + }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { role: "orchestrator" }); + const response = await callTool(handler, "start_cli_session", { + laneId: "lane-1", + provider: "codex", + permissionMode: "edit", + initialInput: "fix failing tests", + cols: 90, + rows: 24, + }); + + expect(response?.isError).toBeUndefined(); + expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-1", + title: "Codex", + toolType: "codex", + cols: 90, + rows: 24, + command: "codex", + startupCommand: expect.stringContaining("codex --no-alt-screen"), + }), + ); + expect(fixture.runtime.ptyService.writeBySessionId).toHaveBeenCalledWith("session-1", "fix failing tests\r"); + expect(response.structuredContent).toMatchObject({ + provider: "codex", + laneId: "lane-1", + ptyId: "pty-1", + sessionId: "session-1", + initialInputWritten: true, + }); + }); + + it("preassigns Claude session ids for start_cli_session launches", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { role: "orchestrator" }); + const response = await callTool(handler, "start_cli_session", { + laneId: "lane-1", + provider: "claude", + permissionMode: "default", + }); + + expect(response?.isError).toBeUndefined(); + const createCall = fixture.runtime.ptyService.create.mock.calls.at(-1)?.[0]; + expect(createCall.sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + expect(createCall.allowNewSessionId).toBe(true); + expect(createCall.startupCommand).toContain("--session-id"); + expect(createCall.startupCommand).toContain(createCall.sessionId); + expect(createCall.toolType).toBe("claude"); + }); + + it("resumes start_cli_session from stored terminal metadata", async () => { + const fixture = createRuntime(); + fixture.runtime.sessionService.get.mockReturnValue({ + id: "session-existing", + laneId: "lane-1", + ptyId: "pty-existing", + tracked: true, + toolType: "codex", + title: "Codex", + status: "exited", + resumeCommand: "codex resume picker", + resumeMetadata: { + provider: "codex", + targetKind: "thread", + targetId: "thread-77", + launch: { permissionMode: "edit" }, + }, + }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { role: "orchestrator" }); + const response = await callTool(handler, "start_cli_session", { + laneId: "lane-1", + provider: "codex", + resumeSessionId: "session-existing", + }); + + expect(response?.isError).toBeUndefined(); + expect(fixture.runtime.ptyService.create).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "session-existing", + startupCommand: "codex --no-alt-screen --sandbox workspace-write --ask-for-approval untrusted resume thread-77", + }), + ); + }); + it("starts spawn_agent without writing an attached ADE server config", async () => { const fixture = createRuntime(); fixture.runtime.workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-spawn-workspace-")); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 18eff7061..9f7772b4f 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -41,6 +41,15 @@ import { } from "../../desktop/src/shared/types"; import type { PrActionRun, PrCheck, PrComment, PrReviewThread } from "../../desktop/src/shared/types/prs"; import { resolveAdeLayout } from "../../desktop/src/shared/adeLayout"; +import { + buildTrackedCliLaunchCommand, + buildTrackedCliResumeCommand, + LAUNCH_PROFILE_TITLE, + LAUNCH_PROFILE_TOOL_TYPE, + type CliProvider, + type LaunchProfile, +} from "../../desktop/src/shared/cliLaunch"; +import type { AgentChatPermissionMode, TerminalSessionSummary } from "../../desktop/src/shared/types"; import type { AdeRuntime } from "./bootstrap"; import { JsonRpcError, JsonRpcErrorCode, type JsonRpcHandler, type JsonRpcRequest } from "./jsonrpc"; @@ -217,6 +226,29 @@ const TOOL_SPECS: ToolSpec[] = [ } } }, + { + name: "start_cli_session", + description: "Start or resume a tracked ADE Work CLI terminal for an allowlisted provider, using the same launch helpers as desktop and mobile.", + inputSchema: { + type: "object", + required: ["laneId", "provider"], + additionalProperties: false, + properties: { + laneId: { type: "string", minLength: 1 }, + provider: { type: "string", enum: ["claude", "codex", "cursor", "droid", "opencode", "shell"] }, + permissionMode: { type: "string", enum: ["default", "plan", "edit", "full-auto", "config-toml"], default: "default" }, + title: { type: "string" }, + initialInput: { type: "string" }, + cols: { type: "number", minimum: 20, maximum: 240, default: 120 }, + rows: { type: "number", minimum: 4, maximum: 120, default: 36 }, + cwd: { type: "string" }, + chatSessionId: { type: "string" }, + resumeSessionId: { type: "string" }, + resumeTargetId: { type: "string" }, + tracked: { type: "boolean", default: true } + } + } + }, { name: "get_ade_action_status", description: "Check status/progress for long-running ADE actions by operation/test/chat/run/mission identifiers.", @@ -1912,6 +1944,7 @@ const READ_ONLY_TOOLS = new Set([ const MUTATION_TOOLS = new Set([ "create_lane", "run_ade_action", + "start_cli_session", "import_lane", "merge_lane", "git_fetch", @@ -2092,6 +2125,35 @@ function assertNonEmptyString(value: unknown, field: string): string { return text; } +const CLI_SESSION_PROVIDERS: readonly LaunchProfile[] = ["claude", "codex", "cursor", "droid", "opencode", "shell"]; +const CLI_SESSION_PERMISSION_MODES: readonly AgentChatPermissionMode[] = ["default", "plan", "edit", "full-auto", "config-toml"]; + +function parseCliSessionProvider(value: unknown): LaunchProfile { + const provider = asTrimmedString(value).toLowerCase(); + const match = CLI_SESSION_PROVIDERS.find((entry) => entry === provider); + if (!match) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "provider must be one of claude, codex, cursor, droid, opencode, or shell", + ); + } + return match; +} + +function parseCliSessionPermissionMode(value: unknown): AgentChatPermissionMode { + const mode = asTrimmedString(value); + return CLI_SESSION_PERMISSION_MODES.find((entry) => entry === mode) ?? "default"; +} + +function clampInteger(value: unknown, fallback: number, min: number, max: number): number { + const raw = typeof value === "number" && Number.isFinite(value) ? value : fallback; + return Math.max(min, Math.min(max, Math.floor(raw))); +} + +function isCliProvider(provider: LaunchProfile): provider is CliProvider { + return provider !== "shell"; +} + export function resolveComputerUseOwners(session: SessionState, toolArgs: Record): ComputerUseArtifactOwner[] { const owners: ComputerUseArtifactOwner[] = []; const add = ( @@ -4258,6 +4320,79 @@ async function runTool(args: { }; } + if (name === "start_cli_session") { + const laneId = assertNonEmptyString(toolArgs.laneId, "laneId"); + const provider = parseCliSessionProvider(toolArgs.provider); + const permissionMode = parseCliSessionPermissionMode(toolArgs.permissionMode); + const cols = clampInteger(toolArgs.cols, DEFAULT_PTY_COLS, 20, 240); + const rows = clampInteger(toolArgs.rows, DEFAULT_PTY_ROWS, 4, 120); + const title = asOptionalTrimmedString(toolArgs.title) ?? LAUNCH_PROFILE_TITLE[provider]; + const resumeSessionId = asOptionalTrimmedString(toolArgs.resumeSessionId); + const resumeTargetId = asOptionalTrimmedString(toolArgs.resumeTargetId); + const initialInput = asOptionalTrimmedString(toolArgs.initialInput)?.slice(0, 20_000) ?? null; + const ptyService = runtime.ptyService as typeof runtime.ptyService & { + writeBySessionId?: (sessionId: string, data: string) => boolean; + enrichSessions?: (sessions: TerminalSessionSummary[]) => TerminalSessionSummary[]; + }; + const preassignedSessionId = provider === "claude" && !resumeSessionId ? randomUUID() : undefined; + + const launchFields: { startupCommand?: string; command?: string; args?: string[]; env?: Record } = (() => { + if (!isCliProvider(provider)) return {}; + if (resumeSessionId || resumeTargetId) { + const resumeSession = resumeSessionId ? runtime.sessionService.get(resumeSessionId) : null; + const startupCommand = resumeSession?.resumeMetadata + ? buildTrackedCliResumeCommand(resumeSession.resumeMetadata) + : resumeSession?.resumeCommand?.trim() + || buildTrackedCliResumeCommand({ + provider, + targetKind: "session", + targetId: resumeTargetId, + launch: { permissionMode }, + }); + return { startupCommand }; + } + return buildTrackedCliLaunchCommand({ provider, permissionMode, sessionId: preassignedSessionId }); + })(); + + const created = await ptyService.create({ + ...(resumeSessionId || preassignedSessionId ? { sessionId: resumeSessionId ?? preassignedSessionId } : {}), + ...(preassignedSessionId ? { allowNewSessionId: true } : {}), + laneId, + cols, + rows, + title, + tracked: toolArgs.tracked !== false, + toolType: LAUNCH_PROFILE_TOOL_TYPE[provider], + ...(asOptionalTrimmedString(toolArgs.cwd) ? { cwd: asOptionalTrimmedString(toolArgs.cwd)! } : {}), + ...(asOptionalTrimmedString(toolArgs.chatSessionId) ? { chatSessionId: asOptionalTrimmedString(toolArgs.chatSessionId) } : {}), + ...launchFields, + }); + + let initialInputWritten = false; + if (initialInput && isCliProvider(provider)) { + if (typeof ptyService.writeBySessionId !== "function") { + throw new JsonRpcError(JsonRpcErrorCode.internalError, "PTY service does not support session-scoped writes."); + } + initialInputWritten = ptyService.writeBySessionId(created.sessionId, `${initialInput}\r`); + } + + const session = runtime.sessionService.get(created.sessionId) as TerminalSessionSummary | null; + const enrichedSession = session && typeof ptyService.enrichSessions === "function" + ? ptyService.enrichSessions([session])[0] ?? session + : session; + return { + provider, + laneId, + title, + permissionMode, + ptyId: created.ptyId, + sessionId: created.sessionId, + startupCommand: launchFields.startupCommand ?? null, + initialInputWritten, + session: enrichedSession ?? null, + }; + } + if (name === "get_ade_action_status") { const operationId = asOptionalTrimmedString(toolArgs.operationId); const testRunId = asOptionalTrimmedString(toolArgs.testRunId); diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 9a87e302c..c801649ab 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -861,6 +861,58 @@ describe("ADE CLI", () => { }); }); + it("maps provider shell launches to start_cli_session", () => { + const plan = buildCliPlan([ + "shell", + "start-cli", + "codex", + "--lane", + "lane-1", + "--permission-mode", + "edit", + "--message", + "fix the tests", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toEqual({ + name: "start_cli_session", + arguments: expect.objectContaining({ + laneId: "lane-1", + provider: "codex", + permissionMode: "edit", + initialInput: "fix the tests", + title: "Codex", + cols: 120, + rows: 36, + tracked: true, + }), + }); + }); + + it("accepts --provider on shell start as the CLI-session launcher", () => { + const plan = buildCliPlan([ + "shell", + "start", + "--provider", + "claude", + "--lane", + "lane-1", + "--resume-session", + "session-1", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + name: "start_cli_session", + arguments: { + laneId: "lane-1", + provider: "claude", + resumeSessionId: "session-1", + }, + }); + }); + it("renders an empty lane graph placeholder when no lanes are returned", () => { expect(renderLaneGraph({ lanes: [] })).toBe("ADE lanes\n(no lanes)"); expect(renderLaneGraph(null)).toBe("ADE lanes\n(no lanes)"); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 4760084c9..7bc6ee52c 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -12,6 +12,7 @@ import { } from "./cursorCloud"; import { type JsonRpcHandler, type JsonRpcId, type JsonRpcRequest } from "./jsonrpc"; import { isAdeMcpNamedPipePath } from "../../desktop/src/shared/adeMcpIpc"; +import { LAUNCH_PROFILE_TITLE, type LaunchProfile } from "../../desktop/src/shared/cliLaunch"; type JsonObject = Record; @@ -755,6 +756,8 @@ const HELP_BY_COMMAND: Record = { $ ade shell start --lane -- npm test Start a tracked shell session $ ade shell start --lane -c "npm test" Start with a command string + $ ade shell start-cli codex --lane --permission-mode edit + $ ade shell start --provider claude --lane --message "fix tests" $ ade shell start --lane --chat-session -c "npm test" $ ade shell write --data "q" Write data to a PTY $ ade shell resize --cols 120 --rows 36 @@ -2217,7 +2220,14 @@ function buildRunPlan(args: string[]): CliPlan { function buildShellPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "start"; if (sub === "actions") return { kind: "execute", label: "shell actions", steps: [listActionsStep("actions", "pty")] }; + if (sub === "start-cli" || sub === "cli" || sub === "agent-cli") { + return buildCliSessionStartPlan(args); + } if (sub === "start" || sub === "create") { + const provider = readValue(args, ["--provider", "--profile"]); + if (provider) { + return buildCliSessionStartPlan(args, provider); + } const laneId = readLaneId(args); const chatSessionId = asString( readValue(args, ["--chat-session", "--chat-session-id", "--session", "--session-id"]) @@ -2247,6 +2257,33 @@ function buildShellPlan(args: string[]): CliPlan { return { kind: "execute", label: `shell ${sub}`, steps: [actionStep("result", "pty", sub, collectGenericObjectArgs(args))] }; } +function buildCliSessionStartPlan(args: string[], providerArg?: string): CliPlan { + const laneId = requireValue(readLaneId(args), "laneId"); + const provider = requireValue(providerArg ?? readValue(args, ["--provider", "--profile"]) ?? firstPositional(args), "provider") as LaunchProfile; + const promptIndex = args.indexOf("--"); + const initialInput = promptIndex >= 0 + ? args.splice(promptIndex + 1).join(" ").trim() + : readValue(args, ["--message", "--prompt", "--initial-input"]); + if (promptIndex >= 0) args.splice(promptIndex, 1); + + const input = collectGenericObjectArgs(args, { + laneId, + provider, + permissionMode: readValue(args, ["--permission-mode", "--permissions"]) ?? "default", + title: readValue(args, ["--title"]) ?? LAUNCH_PROFILE_TITLE[provider] ?? undefined, + initialInput, + cols: readIntOption(args, ["--cols"], 120), + rows: readIntOption(args, ["--rows"], 36), + cwd: readValue(args, ["--cwd"]), + chatSessionId: readValue(args, ["--chat-session", "--chat-session-id"]), + resumeSessionId: readValue(args, ["--resume-session", "--resume-session-id"]), + resumeTargetId: readValue(args, ["--resume-target", "--resume-target-id", "--target"]), + tracked: !readFlag(args, ["--untracked"]), + }); + + return { kind: "execute", label: "shell start cli", steps: [actionCallStep("result", "start_cli_session", input)] }; +} + function buildTerminalPlan(args: string[]): CliPlan { const sub = firstPositional(args) ?? "active"; if (sub === "actions") return { kind: "execute", label: "terminal actions", steps: [listActionsStep("actions", "terminal")] }; diff --git a/apps/ade-cli/tsconfig.json b/apps/ade-cli/tsconfig.json index 446e738a0..6e74af44a 100644 --- a/apps/ade-cli/tsconfig.json +++ b/apps/ade-cli/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2022", - "lib": ["ES2022"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", "strict": true, diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 6fa8ccad5..cfaca7b99 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -369,8 +369,8 @@ function inferSessionCwdFromTranscriptPath(transcriptPath: string | null | undef return transcriptPath.slice(0, markerIndex) || null; } -const MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024; -const TRANSCRIPT_LIMIT_NOTICE = "\n[ADE] transcript limit reached (8MB). Further output omitted.\n"; +const MAX_TRANSCRIPT_BYTES = 64 * 1024 * 1024; +const TRANSCRIPT_LIMIT_NOTICE = "\n[ADE] transcript limit reached (64MB). Further output omitted.\n"; const RESUME_TARGET_MISSING_COOLDOWN_MS = 10 * 60_000; const RESUME_SCAN_WINDOW_MS = 60_000; diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index 219609d94..f3894a69b 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -1376,7 +1376,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { fs.rmSync(outsideArtifact, { force: true }); }); - it("streams terminal snapshots, live output, exit events, and supports the quick-run seed command", async () => { + it("streams terminal snapshots, live output, exit events, and supports mobile terminal seed commands", async () => { const brainDb = await openKvDb(makeDbPath("ade-sync-terminal-"), createLogger() as any); const projectRoot = makeProjectRoot("ade-sync-terminal-project-"); const workspaceRoot = path.join(projectRoot, "workspace"); @@ -1494,10 +1494,25 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { ], get: () => ({ id: "session-1", + laneId: "lane-1", + laneName: "Primary", + ptyId: "pty-1", + tracked: true, + pinned: false, + goal: "Run tests", + toolType: "codex", + title: "Codex", transcriptPath: path.join(projectRoot, ".ade", "transcripts", "session-1.log"), status: "running", runtimeState: "running", lastOutputPreview: "echo hi", + startedAt: "2026-03-17T00:10:00.000Z", + endedAt: null, + exitCode: null, + headShaStart: null, + headShaEnd: null, + summary: null, + resumeCommand: "codex resume picker", }), readTranscriptTail: async () => "prior output\n", } as any, @@ -1639,6 +1654,71 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { expect((mismatchResult.payload as { ok: boolean; error?: { code: string } }).error?.code).toBe("duplicate_command_mismatch"); expect(createSpy).toHaveBeenCalledTimes(1); + client.ws.send(encodeSyncEnvelope({ + type: "command", + requestId: "cmd-start-cli", + payload: { + commandId: "cmd-start-cli", + action: "work.startCliSession", + args: { + laneId: "lane-1", + provider: "codex", + permissionMode: "edit", + initialInput: "fix from phone", + }, + }, + })); + const startCliAck = await client.queue.next("command_ack"); + expect((startCliAck.payload as { accepted: boolean }).accepted).toBe(true); + const startCliResult = await client.queue.next("command_result"); + const startCliPayload = startCliResult.payload as { + ok: boolean; + result: { sessionId: string; ptyId: string; session: { id: string; toolType: string } }; + }; + expect(startCliPayload.result.sessionId).toBe("session-1"); + expect(startCliPayload.result.ptyId).toBe("pty-1"); + expect(startCliPayload.result.session).toEqual(expect.objectContaining({ + id: "session-1", + toolType: "codex", + })); + expect(createSpy).toHaveBeenCalledTimes(2); + expect(writeBySessionId).toHaveBeenCalledWith("session-1", "fix from phone\r"); + + await waitFor(() => fs.readFileSync(commandLedgerPath, "utf8").includes("cmd-start-cli")); + const startCliLedger = fs.readFileSync(commandLedgerPath, "utf8"); + expect(startCliLedger).toContain("cmd-start-cli"); + expect(startCliLedger).toContain("argsFingerprint"); + expect(startCliLedger).not.toContain("fix from phone"); + + client.ws.send(encodeSyncEnvelope({ + type: "command", + requestId: "cmd-start-cli-retry", + payload: { + commandId: "cmd-start-cli", + action: "work.startCliSession", + args: { + laneId: "lane-1", + provider: "codex", + permissionMode: "edit", + initialInput: "fix from phone", + }, + }, + })); + const startCliReplayAck = await client.queue.next("command_ack"); + expect((startCliReplayAck.payload as { accepted: boolean }).accepted).toBe(true); + const startCliReplayResult = await client.queue.next("command_result"); + const startCliReplayPayload = startCliReplayResult.payload as { + ok: boolean; + result: { sessionId: string; ptyId: string; session: { id: string; toolType: string } }; + }; + expect(startCliReplayPayload.result.sessionId).toBe("session-1"); + expect(startCliReplayPayload.result.ptyId).toBe("pty-1"); + expect(startCliReplayPayload.result.session).toEqual(expect.objectContaining({ + id: "session-1", + toolType: "codex", + })); + expect(createSpy).toHaveBeenCalledTimes(2); + client.ws.send(encodeSyncEnvelope({ type: "command", requestId: "cmd-work-list", diff --git a/apps/desktop/src/main/services/sync/syncHostService.ts b/apps/desktop/src/main/services/sync/syncHostService.ts index 4717c2486..b8ac215a5 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.ts @@ -195,6 +195,7 @@ const PERSISTED_MOBILE_COMMAND_ACTIONS = new Set([ "lanes.presence.release", "notification_prefs", "work.runQuickCommand", + "work.startCliSession", "work.closeSession", "processes.start", "processes.stop", @@ -246,11 +247,12 @@ function persistedMobileCommandResult(action: string, result: SyncCommandResultP }, }; } - if (action === "work.runQuickCommand") { + if (action === "work.runQuickCommand" || action === "work.startCliSession") { const raw = safeObjectValue(result.result); const replayResult: Record = {}; if (typeof raw?.sessionId === "string") replayResult.sessionId = raw.sessionId; if (typeof raw?.ptyId === "string") replayResult.ptyId = raw.ptyId; + if (action === "work.startCliSession" && safeObjectValue(raw?.session)) replayResult.session = raw?.session; return { commandId: result.commandId, ok: true, diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index aae73e19a..edd754749 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -12,6 +12,7 @@ const IOS_REMOTE_COMMAND_ACTIONS = [ "work.updateSessionMeta", "prs.getMobileSnapshot", "work.runQuickCommand", + "work.startCliSession", "work.closeSession", "processes.listDefinitions", "processes.listRuntime", @@ -325,7 +326,8 @@ function createMockQueueLandingService() { function createMockPtyService() { return { - create: vi.fn().mockResolvedValue({ sessionId: "pty-1" }), + create: vi.fn().mockResolvedValue({ sessionId: "pty-1", ptyId: "pty-proc" }), + writeBySessionId: vi.fn().mockReturnValue(true), dispose: vi.fn().mockResolvedValue(undefined), enrichSessions: vi.fn((sessions) => sessions), } as any; @@ -1625,6 +1627,129 @@ describe("createSyncRemoteCommandService", () => { ); }); + it("work.startCliSession builds allowlisted provider launch commands", async () => { + sessionService.get.mockReturnValue({ + id: "pty-1", + laneId: "lane-1", + laneName: "Lane", + ptyId: "pty-1", + tracked: true, + pinned: false, + goal: null, + toolType: "codex", + title: "Codex", + status: "running", + startedAt: "2026-01-01T00:00:00.000Z", + endedAt: null, + exitCode: null, + transcriptPath: "", + headShaStart: null, + headShaEnd: null, + lastOutputPreview: null, + summary: null, + runtimeState: "running", + resumeCommand: null, + }); + const result = await service.execute(makePayload("work.startCliSession", { + laneId: "lane-1", + provider: "codex", + permissionMode: "edit", + initialInput: "fix the tests", + cols: 70, + rows: 24, + })); + expect(ptyService.create).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-1", + title: "Codex", + toolType: "codex", + cols: 70, + rows: 24, + command: "codex", + startupCommand: expect.stringContaining("codex"), + }), + ); + expect(ptyService.writeBySessionId).toHaveBeenCalledWith("pty-1", "fix the tests\r"); + expect(result).toEqual(expect.objectContaining({ + sessionId: "pty-1", + ptyId: "pty-proc", + session: expect.objectContaining({ id: "pty-1" }), + })); + }); + + it("work.startCliSession opens a shell without accepting arbitrary startup commands", async () => { + await service.execute(makePayload("work.startCliSession", { + laneId: "lane-1", + provider: "shell", + startupCommand: "rm -rf nope", + initialInput: "rm -rf also-nope", + })); + expect(ptyService.create).toHaveBeenCalledWith( + expect.not.objectContaining({ + startupCommand: "rm -rf nope", + }), + ); + expect(ptyService.create).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-1", + title: "Shell", + toolType: "shell", + }), + ); + expect(ptyService.writeBySessionId).not.toHaveBeenCalled(); + }); + + it("work.startCliSession rejects unknown providers", async () => { + await expect(service.execute(makePayload("work.startCliSession", { + laneId: "lane-1", + provider: "node -e nope", + }))).rejects.toThrow("work.startCliSession requires provider."); + }); + + it("work.startCliSession pre-assigns a claude --session-id so resume is reliable", async () => { + await service.execute(makePayload("work.startCliSession", { + laneId: "lane-1", + provider: "claude", + permissionMode: "default", + })); + const call = ptyService.create.mock.calls.at(-1)?.[0]; + expect(call?.sessionId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + expect(call?.allowNewSessionId).toBe(true); + expect(call?.startupCommand).toContain("--session-id"); + expect(call?.startupCommand).toContain(call!.sessionId); + expect(call?.toolType).toBe("claude"); + }); + + it("work.startCliSession rebuilds the resume command from stored metadata when resumeSessionId is given", async () => { + sessionService.get.mockReturnValue({ + id: "pty-existing", + laneId: "lane-1", + ptyId: "pty-existing", + toolType: "codex", + title: "Codex", + status: "running", + resumeCommand: "codex resume picker", + resumeMetadata: { + provider: "codex", + targetKind: "thread", + targetId: "thread-77", + launch: { permissionMode: "edit" }, + }, + }); + await service.execute(makePayload("work.startCliSession", { + laneId: "lane-1", + provider: "codex", + resumeSessionId: "pty-existing", + })); + const call = ptyService.create.mock.calls.at(-1)?.[0]; + expect(call?.sessionId).toBe("pty-existing"); + expect(call?.allowNewSessionId).toBe(false); + expect(call?.startupCommand).toBe( + "codex --no-alt-screen --sandbox workspace-write --ask-for-approval untrusted resume thread-77", + ); + expect(call?.command).toBeUndefined(); + }); + it("work.closeSession disposes pty if session has a ptyId", async () => { sessionService.get.mockReturnValue({ ptyId: "pty-42" }); const result = await service.execute(makePayload("work.closeSession", { diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts index caf1b0173..0b2749e0b 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import type { AgentChatCreateArgs, AgentChatArchiveArgs, @@ -94,6 +95,8 @@ import type { SyncRemoteCommandAction, SyncRemoteCommandDescriptor, SyncRemoteCommandPolicy, + SyncStartCliSessionArgs, + SyncStartCliSessionResult, SyncRunQuickCommandArgs, UpdateSessionMetaArgs, UpdateIntegrationProposalArgs, @@ -103,6 +106,13 @@ import type { UpdatePrTitleArgs, WriteTextAtomicArgs, } from "../../../shared/types"; +import { + buildTrackedCliLaunchCommand, + buildTrackedCliResumeCommand, + LAUNCH_PROFILE_TITLE, + LAUNCH_PROFILE_TOOL_TYPE, + resolveTrackedCliResumeCommand, +} from "../../../shared/cliLaunch"; import { normalizePrCreationStrategy } from "../../../shared/prStrategy"; import type { createAgentChatService } from "../chat/agentChatService"; import type { createCtoStateService } from "../cto/ctoStateService"; @@ -483,6 +493,43 @@ function parseQuickCommandArgs(value: Record): SyncRunQuickComm }; } +const CLI_LAUNCH_PROVIDERS = ["claude", "codex", "cursor", "droid", "opencode", "shell"] as const; +const CLI_PERMISSION_MODES = ["default", "plan", "edit", "full-auto", "config-toml"] as const; + +function clampCliDimension(value: number | undefined, fallback: number, min: number, max: number): number { + return Math.max(min, Math.min(max, Math.floor(value ?? fallback))); +} + +function parseCliProvider(value: unknown): SyncStartCliSessionArgs["provider"] { + const provider = asTrimmedString(value)?.toLowerCase(); + const match = CLI_LAUNCH_PROVIDERS.find((p) => p === provider); + if (!match) throw new Error("work.startCliSession requires provider."); + return match; +} + +function parseCliPermissionMode(value: unknown): SyncStartCliSessionArgs["permissionMode"] { + const mode = asTrimmedString(value); + return CLI_PERMISSION_MODES.find((m) => m === mode) ?? "default"; +} + +function parseStartCliSessionArgs(value: Record): SyncStartCliSessionArgs { + const laneId = requireString(value.laneId, "work.startCliSession requires laneId."); + const provider = parseCliProvider(value.provider); + const initialInput = typeof value.initialInput === "string" && value.initialInput.trim().length > 0 + ? value.initialInput.slice(0, 20_000) + : null; + return { + laneId, + provider, + permissionMode: parseCliPermissionMode(value.permissionMode), + title: asTrimmedString(value.title), + initialInput, + cols: asOptionalNumber(value.cols), + rows: asOptionalNumber(value.rows), + resumeSessionId: asTrimmedString(value.resumeSessionId), + }; +} + function isChatToolType(toolType: string | null | undefined): boolean { if (!toolType) return false; const t = toolType.trim().toLowerCase(); @@ -1714,6 +1761,58 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg toolType: (parsed.toolType ?? "run-shell") as TerminalToolType, }); }); + register("work.startCliSession", { viewerAllowed: true, queueable: true }, async (payload) => { + const parsed = parseStartCliSessionArgs(payload); + const cols = clampCliDimension(parsed.cols, 88, 20, 240); + const rows = clampCliDimension(parsed.rows, 28, 4, 120); + const resumeSessionId = parsed.resumeSessionId?.trim() || undefined; + const { provider } = parsed; + const permissionMode = parsed.permissionMode ?? "default"; + const toolType = LAUNCH_PROFILE_TOOL_TYPE[provider] as TerminalToolType; + const title = parsed.title?.trim() || LAUNCH_PROFILE_TITLE[provider]; + const preassignedSessionId = provider === "claude" && !resumeSessionId ? randomUUID() : undefined; + + function resolveLaunch(): { startupCommand?: string; command?: string; args?: string[]; env?: Record } { + if (provider === "shell") return {}; + if (resumeSessionId) { + const resumeSession = args.sessionService.get(resumeSessionId); + const startupCommand = (resumeSession ? resolveTrackedCliResumeCommand(resumeSession) : null) + ?? buildTrackedCliResumeCommand({ + provider, + targetKind: "session", + targetId: null, + launch: { permissionMode }, + }); + return { startupCommand }; + } + return buildTrackedCliLaunchCommand({ provider, permissionMode, sessionId: preassignedSessionId }); + } + + const sessionId = resumeSessionId ?? preassignedSessionId; + const result = await args.ptyService.create({ + ...(sessionId ? { sessionId } : {}), + allowNewSessionId: Boolean(preassignedSessionId), + laneId: parsed.laneId, + title, + tracked: true, + toolType, + cols, + rows, + ...resolveLaunch(), + }); + + if (parsed.initialInput && provider !== "shell") { + args.ptyService.writeBySessionId(result.sessionId, `${parsed.initialInput}\r`); + } + + const session = args.sessionService.get(result.sessionId); + const enriched = session ? args.ptyService.enrichSessions([session])[0] ?? session : null; + return { + sessionId: result.sessionId, + ptyId: result.ptyId, + session: enriched, + } satisfies SyncStartCliSessionResult; + }); register("work.closeSession", { viewerAllowed: true, queueable: true }, async (payload) => { const { sessionId } = parseCloseSessionArgs(payload); const session = args.sessionService.get(sessionId); diff --git a/apps/desktop/src/renderer/components/terminals/cliLaunch.ts b/apps/desktop/src/renderer/components/terminals/cliLaunch.ts index ff5926e24..b0e19be05 100644 --- a/apps/desktop/src/renderer/components/terminals/cliLaunch.ts +++ b/apps/desktop/src/renderer/components/terminals/cliLaunch.ts @@ -1,358 +1 @@ -import type { - AgentChatPermissionMode, - TerminalResumeMetadata, - TerminalSessionSummary, - TerminalToolType, -} from "../../../shared/types"; -import { ADE_CLI_AGENT_GUIDANCE, ADE_CLI_INLINE_GUIDANCE } from "../../../shared/adeCliGuidance"; -import { commandArrayToLine, quoteShellArg } from "../../lib/shell"; - -export type CliProvider = "claude" | "codex" | "cursor" | "droid" | "opencode"; -export type LaunchProfile = CliProvider | "shell"; -export type TrackedCliLaunchCommand = { - command?: string; - args: string[]; - startupCommand: string; - env?: Record; -}; - -/** Maps a `launchPtySession` profile to the `TerminalToolType` recorded on the session. */ -export const LAUNCH_PROFILE_TOOL_TYPE: Record = { - claude: "claude", - codex: "codex", - cursor: "cursor-cli", - droid: "droid", - opencode: "opencode", - shell: "shell", -}; - -/** Default human-readable tab title for a launch profile. */ -export const LAUNCH_PROFILE_TITLE: Record = { - claude: "Claude Code", - codex: "Codex", - cursor: "Cursor Agent CLI", - droid: "Factory Droid CLI", - opencode: "OpenCode CLI", - shell: "Shell", -}; - -export function withCodexNoAltScreen(command: string): string { - const trimmed = command.trim(); - if (!/^codex(?:\s|$)/.test(trimmed)) return trimmed; - if (/(?:^|\s)--no-alt-screen(?:\s|$)/.test(trimmed)) return trimmed; - return trimmed === "codex" - ? "codex --no-alt-screen" - : trimmed.replace(/^codex\b/, "codex --no-alt-screen"); -} - -export function defaultTrackedCliStartupCommand(provider: CliProvider): string { - if (provider === "codex") return withCodexNoAltScreen("codex"); - if (provider === "cursor") return "cursor-agent"; - if (provider === "droid") return "droid"; - if (provider === "opencode") return "opencode"; - return "claude"; -} - -function workTabCliPreamblePrompt(): string { - return [ - "ADE session guidance. Treat this as operating guidance for the CLI session, keep it in mind for future user messages, and wait for the user's next instruction before taking action.", - "", - ADE_CLI_INLINE_GUIDANCE, - ].join("\n"); -} - -export function buildTrackedCliStartupCommand(args: { - provider: CliProvider; - permissionMode: AgentChatPermissionMode; - /** Pre-assigned session ID for Claude CLI (enables reliable resume). */ - sessionId?: string; -}): string { - return buildTrackedCliLaunchCommand(args).startupCommand; -} - -export function buildTrackedCliLaunchCommand(args: { - provider: CliProvider; - permissionMode: AgentChatPermissionMode; - /** Pre-assigned session ID for Claude CLI (enables reliable resume). */ - sessionId?: string; -}): TrackedCliLaunchCommand { - if (args.provider === "claude") { - const commandArgs: string[] = []; - // Inject --session-id so we know the Claude session ID upfront for resume. - if (args.sessionId) { - commandArgs.push("--session-id", args.sessionId); - } - commandArgs.push("--append-system-prompt", ADE_CLI_AGENT_GUIDANCE); - commandArgs.push(...permissionModeToClaudeFlag(args.permissionMode)); - return { - command: "claude", - args: commandArgs, - startupCommand: commandArrayToLine(["claude", ...commandArgs]), - }; - } - - if (args.provider === "codex") { - const commandArgs: string[] = [ - "--no-alt-screen", - ...permissionModeToCodexFlags(args.permissionMode), - workTabCliPreamblePrompt(), - ]; - return { - command: "codex", - args: commandArgs, - startupCommand: commandArrayToLine(["codex", ...commandArgs]), - }; - } - - if (args.provider === "cursor") { - const prompt = workTabCliPreamblePrompt(); - const commandArgs = [...permissionModeToCursorFlags(args.permissionMode), prompt]; - const startupCommand = buildCursorPrecreatedChatCommand({ - permissionMode: args.permissionMode, - prompt, - }); - return { - args: commandArgs, - startupCommand, - }; - } - - if (args.provider === "droid") { - const prompt = workTabCliPreamblePrompt(); - return { - args: [...permissionModeToDroidExecFlags(args.permissionMode), prompt], - startupCommand: buildDroidCommandLine({ permissionMode: args.permissionMode, prompt }), - }; - } - - const opencode = buildOpenCodeCommandParts({ - permissionMode: args.permissionMode, - prompt: workTabCliPreamblePrompt(), - }); - return { - command: "opencode", - args: opencode.args, - startupCommand: opencode.startupCommand, - ...(opencode.env ? { env: opencode.env } : {}), - }; -} - -function permissionModeToClaudeFlag(permissionMode: AgentChatPermissionMode | null | undefined): string[] { - if (permissionMode === "full-auto") return ["--dangerously-skip-permissions"]; - if (permissionMode === "edit") return ["--permission-mode", "acceptEdits"]; - if (permissionMode === "default") return ["--permission-mode", "default"]; - return ["--permission-mode", "plan"]; -} - -function permissionModeToCodexFlags(permissionMode: AgentChatPermissionMode | null | undefined): string[] { - if (permissionMode === "full-auto") return ["--dangerously-bypass-approvals-and-sandbox"]; - if (permissionMode === "default") return ["--full-auto"]; - if (permissionMode === "edit") return ["--sandbox", "workspace-write", "--ask-for-approval", "untrusted"]; - if (permissionMode === "plan") return ["--sandbox", "read-only", "--ask-for-approval", "on-request"]; - return []; -} - -function permissionModeToCursorFlags(permissionMode: AgentChatPermissionMode | null | undefined): string[] { - if (permissionMode === "full-auto") return ["--force"]; - if (permissionMode === "plan") return ["--mode", "plan"]; - if (permissionMode === "edit") return ["--mode", "ask"]; - return []; -} - -function permissionModeToDroidExecFlags(permissionMode: AgentChatPermissionMode | null | undefined): string[] { - if (permissionMode === "full-auto") return ["--auto", "high"]; - if (permissionMode === "default") return ["--auto", "medium"]; - if (permissionMode === "edit") return ["--auto", "low"]; - return []; -} - -function droidSettingsJson(permissionMode: AgentChatPermissionMode | null | undefined): string { - const sessionDefaultSettings = (() => { - if (permissionMode === "full-auto") return { interactionMode: "auto", autonomyLevel: "high" }; - if (permissionMode === "default") return { interactionMode: "auto", autonomyLevel: "medium" }; - if (permissionMode === "edit") return { interactionMode: "auto", autonomyLevel: "low" }; - return { interactionMode: "spec", autonomyLevel: "off" }; - })(); - return JSON.stringify({ sessionDefaultSettings }); -} - -function buildDroidCommandLine(args: { - permissionMode: AgentChatPermissionMode | null | undefined; - prompt?: string; - resumeTarget?: string | null; -}): string { - const settingsJson = droidSettingsJson(args.permissionMode); - const droidArgs = ["droid", "--settings", "$ADE_DROID_SETTINGS"]; - if (args.resumeTarget !== undefined) { - droidArgs.push("--resume"); - if (args.resumeTarget) droidArgs.push(args.resumeTarget); - } - if (args.prompt) droidArgs.push(args.prompt); - const droidCommand = commandArrayToLine(droidArgs) - .replace(quoteShellArg("$ADE_DROID_SETTINGS"), "\"$ADE_DROID_SETTINGS\""); - return [ - "ADE_DROID_SETTINGS=\"$(mktemp \"${TMPDIR:-/tmp}/ade-droid-settings.XXXXXX.json\")\"", - `printf %s ${quoteShellArg(settingsJson)} > "$ADE_DROID_SETTINGS"`, - `${droidCommand}; ADE_DROID_STATUS=$?; rm -f "$ADE_DROID_SETTINGS"; exit $ADE_DROID_STATUS`, - ].join(" && "); -} - -function buildCursorPrecreatedChatCommand(args: { - permissionMode: AgentChatPermissionMode | null | undefined; - prompt: string; -}): string { - const commandArgs = [ - "cursor-agent", - ...permissionModeToCursorFlags(args.permissionMode), - "--resume", - "$ADE_CURSOR_CHAT_ID", - args.prompt, - ]; - const command = commandArrayToLine(commandArgs) - .replace(quoteShellArg("$ADE_CURSOR_CHAT_ID"), "\"$ADE_CURSOR_CHAT_ID\""); - return [ - "ADE_CURSOR_CHAT_ID=\"$(cursor-agent create-chat)\"", - "[ -n \"$ADE_CURSOR_CHAT_ID\" ] || { echo \"[ADE] cursor-agent create-chat returned no chat id\" >&2; exit 1; }", - "printf %s\\\\n \"[ADE] Resume with cursor-agent --resume ${ADE_CURSOR_CHAT_ID}\"", - command, - ].join(" && "); -} - -const OPENCODE_INLINE_CONFIG_ENV = "OPENCODE_CONFIG_CONTENT"; - -function openCodePermissionValue(permissionMode: AgentChatPermissionMode | null | undefined): string | Record | null { - if (permissionMode === "config-toml") return null; - if (permissionMode === "full-auto") return "allow"; - if (permissionMode === "edit") return { "*": "ask", edit: "allow" }; - if (permissionMode === "plan") return { "*": "ask", edit: "deny", bash: "deny" }; - return { "*": "ask" }; -} - -function openCodeConfigEnv(permissionMode: AgentChatPermissionMode | null | undefined): string | null { - const permission = openCodePermissionValue(permissionMode); - return permission ? JSON.stringify({ permission }) : null; -} - -function openCodeEnvAssignment(permissionMode: AgentChatPermissionMode | null | undefined): string { - const config = openCodeConfigEnv(permissionMode); - return config ? `${OPENCODE_INLINE_CONFIG_ENV}=${quoteShellArg(config)} ` : ""; -} - -function permissionModeToOpenCodeArgs(permissionMode: AgentChatPermissionMode | null | undefined): string[] { - return permissionMode === "plan" ? ["--agent", "plan"] : []; -} - -function buildOpenCodeCommandParts(args: { - permissionMode: AgentChatPermissionMode | null | undefined; - prompt?: string; - resumeTarget?: string | null; - continueLast?: boolean; -}): { args: string[]; startupCommand: string; env?: Record } { - const commandArgs = [...permissionModeToOpenCodeArgs(args.permissionMode)]; - if (args.resumeTarget) { - commandArgs.push("--session", args.resumeTarget); - } else if (args.continueLast) { - commandArgs.push("--continue"); - } - if (args.prompt) commandArgs.push("--prompt", args.prompt); - const config = openCodeConfigEnv(args.permissionMode); - return { - args: commandArgs, - startupCommand: `${openCodeEnvAssignment(args.permissionMode)}${commandArrayToLine(["opencode", ...commandArgs])}`, - ...(config ? { env: { [OPENCODE_INLINE_CONFIG_ENV]: config } } : {}), - }; -} - -export function buildTrackedCliResumeCommand(metadata: TerminalResumeMetadata): string { - const targetId = metadata.targetId?.trim() ?? ""; - if (metadata.provider === "claude") { - const parts = ["claude", ...permissionModeToClaudeFlag(metadata.launch.permissionMode)]; - parts.push("--resume"); - if (targetId) parts.push(targetId); - return commandArrayToLine(parts); - } - - if (metadata.provider === "codex") { - const parts = ["codex", "--no-alt-screen", ...permissionModeToCodexFlags(metadata.launch.permissionMode)]; - parts.push("resume"); - if (targetId) parts.push(targetId); - return commandArrayToLine(parts); - } - - if (metadata.provider === "cursor") { - const parts = ["cursor-agent", ...permissionModeToCursorFlags(metadata.launch.permissionMode)]; - if (targetId) { - parts.push("--resume", targetId); - } else { - parts.push("--continue"); - } - return commandArrayToLine(parts); - } - - if (metadata.provider === "droid") { - return buildDroidCommandLine({ - permissionMode: metadata.launch.permissionMode, - resumeTarget: targetId || null, - }); - } - - const opencode = buildOpenCodeCommandParts({ - permissionMode: metadata.launch.permissionMode, - resumeTarget: targetId || null, - continueLast: !targetId, - }); - return opencode.startupCommand; -} - -export function resolveTrackedCliResumeCommand(session: Pick): string | null { - if (session.resumeMetadata) { - return buildTrackedCliResumeCommand(session.resumeMetadata); - } - const command = session.resumeCommand?.trim() ?? ""; - return command.length > 0 ? command : null; -} - -/** - * Resolve `pty.create` launch fields, treating caller-supplied overrides as - * atomic so we never mix the caller's `startupCommand` with default - * `command`/`args` (or vice versa). If the caller passed *any* override field, - * we use exactly what they supplied — defaults are skipped entirely. Only - * when the caller passed nothing do we fall back to the profile's default - * launch command. - */ -export function resolveLaunchFields

(args: { - profile: P; - permissionMode?: AgentChatPermissionMode; - startupCommand?: string; - command?: string; - args?: string[]; - env?: Record; -}): { startupCommand?: string; command?: string; args?: string[]; env?: Record } { - const callerHasOverride = - args.startupCommand !== undefined - || args.command !== undefined - || args.args !== undefined - || args.env !== undefined; - - if (callerHasOverride) { - return { - ...(args.startupCommand !== undefined ? { startupCommand: args.startupCommand } : {}), - ...(args.command !== undefined ? { command: args.command } : {}), - ...(args.args !== undefined ? { args: args.args } : {}), - ...(args.env !== undefined ? { env: args.env } : {}), - }; - } - - if (args.profile === "shell") return {}; - - const defaultLaunch = buildTrackedCliLaunchCommand({ - provider: args.profile, - permissionMode: args.permissionMode ?? "default", - }); - return { - startupCommand: defaultLaunch.startupCommand, - ...(defaultLaunch.command !== undefined ? { command: defaultLaunch.command } : {}), - args: defaultLaunch.args, - ...(defaultLaunch.env ? { env: defaultLaunch.env } : {}), - }; -} +export * from "../../../shared/cliLaunch"; diff --git a/apps/desktop/src/renderer/lib/shell.ts b/apps/desktop/src/renderer/lib/shell.ts index f06243195..0e6b3eb36 100644 --- a/apps/desktop/src/renderer/lib/shell.ts +++ b/apps/desktop/src/renderer/lib/shell.ts @@ -1,194 +1 @@ -/** Shared shell-quoting and command-line parsing utilities. */ - -type ShellPlatform = NodeJS.Platform | "browser"; - -function currentShellPlatform(): ShellPlatform { - if (typeof navigator !== "undefined" && /win/i.test(navigator.platform)) return "win32"; - if (typeof process !== "undefined" && typeof process.platform === "string") return process.platform; - return "browser"; -} - -function isWindowsPlatform(platform: ShellPlatform = currentShellPlatform()): boolean { - return platform === "win32"; -} - -function quoteWindowsArg(arg: string): string { - if (!arg.length) return '""'; - if (!/[\s"]/.test(arg)) return arg; - let quoted = "\""; - let backslashes = 0; - for (const char of arg) { - if (char === "\\") { - backslashes += 1; - continue; - } - if (char === "\"") { - quoted += "\\".repeat(backslashes * 2); - quoted += "\"\""; - } else { - quoted += "\\".repeat(backslashes); - quoted += char; - } - backslashes = 0; - } - quoted += "\\".repeat(backslashes * 2); - quoted += "\""; - return quoted; -} - -/** Quote a single shell argument, adding double quotes if needed. */ -export function quoteShellArg(arg: string, options: { platform?: ShellPlatform } = {}): string { - if (!arg.length) return '""'; - if (isWindowsPlatform(options.platform)) { - return quoteWindowsArg(arg); - } - if (/^[a-zA-Z0-9_.:@%+=,-]+$/.test(arg)) return arg; - // If the argument contains line terminators or other ANSI control bytes, - // use ANSI-C quoting (`$'...'`). Plain double-quoting preserves them - // literally to the receiving program, but when the resulting line is - // injected into an interactive PTY shell via `pty.write` the terminal's - // line discipline fires on every embedded \n, producing PS2 continuation - // noise mid-command. ANSI-C quoting keeps the line single-line on the wire - // and lets the shell expand the escapes back into the original bytes. - if (/[\n\r\t\v\f]/.test(arg)) { - const escaped = arg - .replace(/\\/g, "\\\\") - .replace(/'/g, "\\'") - .replace(/\n/g, "\\n") - .replace(/\r/g, "\\r") - .replace(/\t/g, "\\t") - .replace(/\v/g, "\\v") - .replace(/\f/g, "\\f"); - return `$'${escaped}'`; - } - return `"${arg.replace(/(["\\$`])/g, "\\$1")}"`; -} - -/** Join an array of command args into a shell-safe command line. */ -export function commandArrayToLine(command: string[], options: { platform?: ShellPlatform } = {}): string { - if (!command.length) return ""; - return command.map((arg) => quoteShellArg(arg, options)).join(" "); -} - -/** Parse a shell-like command line into an array of arguments. */ -export function parseCommandLine(input: string, options: { platform?: ShellPlatform } = {}): string[] { - if (isWindowsPlatform(options.platform)) return parseWindowsCommandLine(input); - - const out: string[] = []; - let current = ""; - let quote: '"' | "'" | null = null; - let escaped = false; - - for (let i = 0; i < input.length; i += 1) { - const ch = input[i]!; - - if (escaped) { - current += ch; - escaped = false; - continue; - } - - if (quote === "'") { - if (ch === "'") quote = null; - else current += ch; - continue; - } - - if (quote === '"') { - if (ch === '"') { - quote = null; - } else if (ch === "\\") { - const next = input[i + 1]; - if (next == null) current += "\\"; - else { - i += 1; - current += next; - } - } else { - current += ch; - } - continue; - } - - if (ch === "\\") { - escaped = true; - continue; - } - if (ch === "'" || ch === '"') { - quote = ch; - continue; - } - if (/\s/.test(ch)) { - if (current.length) { - out.push(current); - current = ""; - } - continue; - } - current += ch; - } - - if (escaped) current += "\\"; - if (quote != null) throw new Error("Unclosed quote in command line"); - if (current.length) out.push(current); - return out; -} - -function parseWindowsCommandLine(input: string): string[] { - const out: string[] = []; - let current = ""; - let inQuotes = false; - - for (let i = 0; i < input.length; i += 1) { - const ch = input[i]!; - - if (ch === "\\") { - let end = i; - while (input[end] === "\\") end += 1; - const count = end - i; - if (input[end] === '"') { - current += "\\".repeat(Math.floor(count / 2)); - if (count % 2 === 0) { - if (inQuotes && input[end + 1] === '"') { - current += '"'; - i = end + 1; - } else { - inQuotes = !inQuotes; - i = end; - } - } else { - current += '"'; - i = end; - } - } else { - current += "\\".repeat(count); - i = end - 1; - } - continue; - } - - if (ch === '"') { - if (inQuotes && input[i + 1] === '"') { - current += '"'; - i += 1; - } else { - inQuotes = !inQuotes; - } - continue; - } - - if (!inQuotes && /\s/.test(ch)) { - if (current.length) { - out.push(current); - current = ""; - } - continue; - } - - current += ch; - } - - if (inQuotes) throw new Error("Unclosed quote in command line"); - if (current.length) out.push(current); - return out; -} +export * from "../../shared/shell"; diff --git a/apps/desktop/src/shared/cliLaunch.ts b/apps/desktop/src/shared/cliLaunch.ts new file mode 100644 index 000000000..5f260836f --- /dev/null +++ b/apps/desktop/src/shared/cliLaunch.ts @@ -0,0 +1,358 @@ +import type { + AgentChatPermissionMode, + TerminalResumeMetadata, + TerminalSessionSummary, + TerminalToolType, +} from "./types"; +import { ADE_CLI_AGENT_GUIDANCE, ADE_CLI_INLINE_GUIDANCE } from "./adeCliGuidance"; +import { commandArrayToLine, quoteShellArg } from "./shell"; + +export type CliProvider = "claude" | "codex" | "cursor" | "droid" | "opencode"; +export type LaunchProfile = CliProvider | "shell"; +export type TrackedCliLaunchCommand = { + command?: string; + args: string[]; + startupCommand: string; + env?: Record; +}; + +/** Maps a `launchPtySession` profile to the `TerminalToolType` recorded on the session. */ +export const LAUNCH_PROFILE_TOOL_TYPE: Record = { + claude: "claude", + codex: "codex", + cursor: "cursor-cli", + droid: "droid", + opencode: "opencode", + shell: "shell", +}; + +/** Default human-readable tab title for a launch profile. */ +export const LAUNCH_PROFILE_TITLE: Record = { + claude: "Claude Code", + codex: "Codex", + cursor: "Cursor Agent CLI", + droid: "Factory Droid CLI", + opencode: "OpenCode CLI", + shell: "Shell", +}; + +export function withCodexNoAltScreen(command: string): string { + const trimmed = command.trim(); + if (!/^codex(?:\s|$)/.test(trimmed)) return trimmed; + if (/(?:^|\s)--no-alt-screen(?:\s|$)/.test(trimmed)) return trimmed; + return trimmed === "codex" + ? "codex --no-alt-screen" + : trimmed.replace(/^codex\b/, "codex --no-alt-screen"); +} + +export function defaultTrackedCliStartupCommand(provider: CliProvider): string { + if (provider === "codex") return withCodexNoAltScreen("codex"); + if (provider === "cursor") return "cursor-agent"; + if (provider === "droid") return "droid"; + if (provider === "opencode") return "opencode"; + return "claude"; +} + +function workTabCliPreamblePrompt(): string { + return [ + "ADE session guidance. Treat this as operating guidance for the CLI session, keep it in mind for future user messages, and wait for the user's next instruction before taking action.", + "", + ADE_CLI_INLINE_GUIDANCE, + ].join("\n"); +} + +export function buildTrackedCliStartupCommand(args: { + provider: CliProvider; + permissionMode: AgentChatPermissionMode; + /** Pre-assigned session ID for Claude CLI (enables reliable resume). */ + sessionId?: string; +}): string { + return buildTrackedCliLaunchCommand(args).startupCommand; +} + +export function buildTrackedCliLaunchCommand(args: { + provider: CliProvider; + permissionMode: AgentChatPermissionMode; + /** Pre-assigned session ID for Claude CLI (enables reliable resume). */ + sessionId?: string; +}): TrackedCliLaunchCommand { + if (args.provider === "claude") { + const commandArgs: string[] = []; + // Inject --session-id so we know the Claude session ID upfront for resume. + if (args.sessionId) { + commandArgs.push("--session-id", args.sessionId); + } + commandArgs.push("--append-system-prompt", ADE_CLI_AGENT_GUIDANCE); + commandArgs.push(...permissionModeToClaudeFlag(args.permissionMode)); + return { + command: "claude", + args: commandArgs, + startupCommand: commandArrayToLine(["claude", ...commandArgs]), + }; + } + + if (args.provider === "codex") { + const commandArgs: string[] = [ + "--no-alt-screen", + ...permissionModeToCodexFlags(args.permissionMode), + workTabCliPreamblePrompt(), + ]; + return { + command: "codex", + args: commandArgs, + startupCommand: commandArrayToLine(["codex", ...commandArgs]), + }; + } + + if (args.provider === "cursor") { + const prompt = workTabCliPreamblePrompt(); + const commandArgs = [...permissionModeToCursorFlags(args.permissionMode), prompt]; + const startupCommand = buildCursorPrecreatedChatCommand({ + permissionMode: args.permissionMode, + prompt, + }); + return { + args: commandArgs, + startupCommand, + }; + } + + if (args.provider === "droid") { + const prompt = workTabCliPreamblePrompt(); + return { + args: [...permissionModeToDroidExecFlags(args.permissionMode), prompt], + startupCommand: buildDroidCommandLine({ permissionMode: args.permissionMode, prompt }), + }; + } + + const opencode = buildOpenCodeCommandParts({ + permissionMode: args.permissionMode, + prompt: workTabCliPreamblePrompt(), + }); + return { + command: "opencode", + args: opencode.args, + startupCommand: opencode.startupCommand, + ...(opencode.env ? { env: opencode.env } : {}), + }; +} + +function permissionModeToClaudeFlag(permissionMode: AgentChatPermissionMode | null | undefined): string[] { + if (permissionMode === "full-auto") return ["--dangerously-skip-permissions"]; + if (permissionMode === "edit") return ["--permission-mode", "acceptEdits"]; + if (permissionMode === "default") return ["--permission-mode", "default"]; + return ["--permission-mode", "plan"]; +} + +function permissionModeToCodexFlags(permissionMode: AgentChatPermissionMode | null | undefined): string[] { + if (permissionMode === "full-auto") return ["--dangerously-bypass-approvals-and-sandbox"]; + if (permissionMode === "default") return ["--full-auto"]; + if (permissionMode === "edit") return ["--sandbox", "workspace-write", "--ask-for-approval", "untrusted"]; + if (permissionMode === "plan") return ["--sandbox", "read-only", "--ask-for-approval", "on-request"]; + return []; +} + +function permissionModeToCursorFlags(permissionMode: AgentChatPermissionMode | null | undefined): string[] { + if (permissionMode === "full-auto") return ["--force"]; + if (permissionMode === "plan") return ["--mode", "plan"]; + if (permissionMode === "edit") return ["--mode", "ask"]; + return []; +} + +function permissionModeToDroidExecFlags(permissionMode: AgentChatPermissionMode | null | undefined): string[] { + if (permissionMode === "full-auto") return ["--auto", "high"]; + if (permissionMode === "default") return ["--auto", "medium"]; + if (permissionMode === "edit") return ["--auto", "low"]; + return []; +} + +function droidSettingsJson(permissionMode: AgentChatPermissionMode | null | undefined): string { + const sessionDefaultSettings = (() => { + if (permissionMode === "full-auto") return { interactionMode: "auto", autonomyLevel: "high" }; + if (permissionMode === "default") return { interactionMode: "auto", autonomyLevel: "medium" }; + if (permissionMode === "edit") return { interactionMode: "auto", autonomyLevel: "low" }; + return { interactionMode: "spec", autonomyLevel: "off" }; + })(); + return JSON.stringify({ sessionDefaultSettings }); +} + +function buildDroidCommandLine(args: { + permissionMode: AgentChatPermissionMode | null | undefined; + prompt?: string; + resumeTarget?: string | null; +}): string { + const settingsJson = droidSettingsJson(args.permissionMode); + const droidArgs = ["droid", "--settings", "$ADE_DROID_SETTINGS"]; + if (args.resumeTarget !== undefined) { + droidArgs.push("--resume"); + if (args.resumeTarget) droidArgs.push(args.resumeTarget); + } + if (args.prompt) droidArgs.push(args.prompt); + const droidCommand = commandArrayToLine(droidArgs) + .replace(quoteShellArg("$ADE_DROID_SETTINGS"), "\"$ADE_DROID_SETTINGS\""); + return [ + "ADE_DROID_SETTINGS=\"$(mktemp \"${TMPDIR:-/tmp}/ade-droid-settings.XXXXXX.json\")\"", + `printf %s ${quoteShellArg(settingsJson)} > "$ADE_DROID_SETTINGS"`, + `${droidCommand}; ADE_DROID_STATUS=$?; rm -f "$ADE_DROID_SETTINGS"; exit $ADE_DROID_STATUS`, + ].join(" && "); +} + +function buildCursorPrecreatedChatCommand(args: { + permissionMode: AgentChatPermissionMode | null | undefined; + prompt: string; +}): string { + const commandArgs = [ + "cursor-agent", + ...permissionModeToCursorFlags(args.permissionMode), + "--resume", + "$ADE_CURSOR_CHAT_ID", + args.prompt, + ]; + const command = commandArrayToLine(commandArgs) + .replace(quoteShellArg("$ADE_CURSOR_CHAT_ID"), "\"$ADE_CURSOR_CHAT_ID\""); + return [ + "ADE_CURSOR_CHAT_ID=\"$(cursor-agent create-chat)\"", + "[ -n \"$ADE_CURSOR_CHAT_ID\" ] || { echo \"[ADE] cursor-agent create-chat returned no chat id\" >&2; exit 1; }", + "printf %s\\\\n \"[ADE] Resume with cursor-agent --resume ${ADE_CURSOR_CHAT_ID}\"", + command, + ].join(" && "); +} + +const OPENCODE_INLINE_CONFIG_ENV = "OPENCODE_CONFIG_CONTENT"; + +function openCodePermissionValue(permissionMode: AgentChatPermissionMode | null | undefined): string | Record | null { + if (permissionMode === "config-toml") return null; + if (permissionMode === "full-auto") return "allow"; + if (permissionMode === "edit") return { "*": "ask", edit: "allow" }; + if (permissionMode === "plan") return { "*": "ask", edit: "deny", bash: "deny" }; + return { "*": "ask" }; +} + +function openCodeConfigEnv(permissionMode: AgentChatPermissionMode | null | undefined): string | null { + const permission = openCodePermissionValue(permissionMode); + return permission ? JSON.stringify({ permission }) : null; +} + +function openCodeEnvAssignment(permissionMode: AgentChatPermissionMode | null | undefined): string { + const config = openCodeConfigEnv(permissionMode); + return config ? `${OPENCODE_INLINE_CONFIG_ENV}=${quoteShellArg(config)} ` : ""; +} + +function permissionModeToOpenCodeArgs(permissionMode: AgentChatPermissionMode | null | undefined): string[] { + return permissionMode === "plan" ? ["--agent", "plan"] : []; +} + +function buildOpenCodeCommandParts(args: { + permissionMode: AgentChatPermissionMode | null | undefined; + prompt?: string; + resumeTarget?: string | null; + continueLast?: boolean; +}): { args: string[]; startupCommand: string; env?: Record } { + const commandArgs = [...permissionModeToOpenCodeArgs(args.permissionMode)]; + if (args.resumeTarget) { + commandArgs.push("--session", args.resumeTarget); + } else if (args.continueLast) { + commandArgs.push("--continue"); + } + if (args.prompt) commandArgs.push("--prompt", args.prompt); + const config = openCodeConfigEnv(args.permissionMode); + return { + args: commandArgs, + startupCommand: `${openCodeEnvAssignment(args.permissionMode)}${commandArrayToLine(["opencode", ...commandArgs])}`, + ...(config ? { env: { [OPENCODE_INLINE_CONFIG_ENV]: config } } : {}), + }; +} + +export function buildTrackedCliResumeCommand(metadata: TerminalResumeMetadata): string { + const targetId = metadata.targetId?.trim() ?? ""; + if (metadata.provider === "claude") { + const parts = ["claude", ...permissionModeToClaudeFlag(metadata.launch.permissionMode)]; + parts.push("--resume"); + if (targetId) parts.push(targetId); + return commandArrayToLine(parts); + } + + if (metadata.provider === "codex") { + const parts = ["codex", "--no-alt-screen", ...permissionModeToCodexFlags(metadata.launch.permissionMode)]; + parts.push("resume"); + if (targetId) parts.push(targetId); + return commandArrayToLine(parts); + } + + if (metadata.provider === "cursor") { + const parts = ["cursor-agent", ...permissionModeToCursorFlags(metadata.launch.permissionMode)]; + if (targetId) { + parts.push("--resume", targetId); + } else { + parts.push("--continue"); + } + return commandArrayToLine(parts); + } + + if (metadata.provider === "droid") { + return buildDroidCommandLine({ + permissionMode: metadata.launch.permissionMode, + resumeTarget: targetId || null, + }); + } + + const opencode = buildOpenCodeCommandParts({ + permissionMode: metadata.launch.permissionMode, + resumeTarget: targetId || null, + continueLast: !targetId, + }); + return opencode.startupCommand; +} + +export function resolveTrackedCliResumeCommand(session: Pick): string | null { + if (session.resumeMetadata) { + return buildTrackedCliResumeCommand(session.resumeMetadata); + } + const command = session.resumeCommand?.trim() ?? ""; + return command.length > 0 ? command : null; +} + +/** + * Resolve `pty.create` launch fields, treating caller-supplied overrides as + * atomic so we never mix the caller's `startupCommand` with default + * `command`/`args` (or vice versa). If the caller passed *any* override field, + * we use exactly what they supplied — defaults are skipped entirely. Only + * when the caller passed nothing do we fall back to the profile's default + * launch command. + */ +export function resolveLaunchFields

(args: { + profile: P; + permissionMode?: AgentChatPermissionMode; + startupCommand?: string; + command?: string; + args?: string[]; + env?: Record; +}): { startupCommand?: string; command?: string; args?: string[]; env?: Record } { + const callerHasOverride = + args.startupCommand !== undefined + || args.command !== undefined + || args.args !== undefined + || args.env !== undefined; + + if (callerHasOverride) { + return { + ...(args.startupCommand !== undefined ? { startupCommand: args.startupCommand } : {}), + ...(args.command !== undefined ? { command: args.command } : {}), + ...(args.args !== undefined ? { args: args.args } : {}), + ...(args.env !== undefined ? { env: args.env } : {}), + }; + } + + if (args.profile === "shell") return {}; + + const defaultLaunch = buildTrackedCliLaunchCommand({ + provider: args.profile, + permissionMode: args.permissionMode ?? "default", + }); + return { + startupCommand: defaultLaunch.startupCommand, + ...(defaultLaunch.command !== undefined ? { command: defaultLaunch.command } : {}), + args: defaultLaunch.args, + ...(defaultLaunch.env ? { env: defaultLaunch.env } : {}), + }; +} diff --git a/apps/desktop/src/shared/shell.ts b/apps/desktop/src/shared/shell.ts new file mode 100644 index 000000000..cfe440d71 --- /dev/null +++ b/apps/desktop/src/shared/shell.ts @@ -0,0 +1,187 @@ +/** Shared shell-quoting and command-line parsing utilities. */ + +type ShellPlatform = NodeJS.Platform | "browser"; + +function currentShellPlatform(): ShellPlatform { + if (typeof navigator !== "undefined" && /win/i.test(navigator.platform)) return "win32"; + if (typeof process !== "undefined" && typeof process.platform === "string") return process.platform; + return "browser"; +} + +function isWindowsPlatform(platform: ShellPlatform = currentShellPlatform()): boolean { + return platform === "win32"; +} + +function quoteWindowsArg(arg: string): string { + if (!arg.length) return "\"\""; + if (!/[\s"]/.test(arg)) return arg; + let quoted = "\""; + let backslashes = 0; + for (const char of arg) { + if (char === "\\") { + backslashes += 1; + continue; + } + if (char === "\"") { + quoted += "\\".repeat(backslashes * 2); + quoted += "\"\""; + } else { + quoted += "\\".repeat(backslashes); + quoted += char; + } + backslashes = 0; + } + quoted += "\\".repeat(backslashes * 2); + quoted += "\""; + return quoted; +} + +/** Quote a single shell argument, adding double quotes if needed. */ +export function quoteShellArg(arg: string, options: { platform?: ShellPlatform } = {}): string { + if (!arg.length) return "\"\""; + if (isWindowsPlatform(options.platform)) { + return quoteWindowsArg(arg); + } + if (/^[a-zA-Z0-9_.:@%+=,-]+$/.test(arg)) return arg; + if (/[\n\r\t\v\f]/.test(arg)) { + const escaped = arg + .replace(/\\/g, "\\\\") + .replace(/'/g, "\\'") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t") + .replace(/\v/g, "\\v") + .replace(/\f/g, "\\f"); + return `$'${escaped}'`; + } + return `"${arg.replace(/(["\\$`])/g, "\\$1")}"`; +} + +/** Join an array of command args into a shell-safe command line. */ +export function commandArrayToLine(command: string[], options: { platform?: ShellPlatform } = {}): string { + if (!command.length) return ""; + return command.map((arg) => quoteShellArg(arg, options)).join(" "); +} + +/** Parse a shell-like command line into an array of arguments. */ +export function parseCommandLine(input: string, options: { platform?: ShellPlatform } = {}): string[] { + if (isWindowsPlatform(options.platform)) return parseWindowsCommandLine(input); + + const out: string[] = []; + let current = ""; + let quote: "\"" | "'" | null = null; + let escaped = false; + + for (let i = 0; i < input.length; i += 1) { + const ch = input[i]!; + + if (escaped) { + current += ch; + escaped = false; + continue; + } + + if (quote === "'") { + if (ch === "'") quote = null; + else current += ch; + continue; + } + + if (quote === "\"") { + if (ch === "\"") { + quote = null; + } else if (ch === "\\") { + const next = input[i + 1]; + if (next == null) current += "\\"; + else { + i += 1; + current += next; + } + } else { + current += ch; + } + continue; + } + + if (ch === "\\") { + escaped = true; + continue; + } + if (ch === "'" || ch === "\"") { + quote = ch; + continue; + } + if (/\s/.test(ch)) { + if (current.length) { + out.push(current); + current = ""; + } + continue; + } + current += ch; + } + + if (escaped) current += "\\"; + if (quote != null) throw new Error("Unclosed quote in command line"); + if (current.length) out.push(current); + return out; +} + +function parseWindowsCommandLine(input: string): string[] { + const out: string[] = []; + let current = ""; + let inQuotes = false; + + for (let i = 0; i < input.length; i += 1) { + const ch = input[i]!; + + if (ch === "\\") { + let end = i; + while (input[end] === "\\") end += 1; + const count = end - i; + if (input[end] === "\"") { + current += "\\".repeat(Math.floor(count / 2)); + if (count % 2 === 0) { + if (inQuotes && input[end + 1] === "\"") { + current += "\""; + i = end + 1; + } else { + inQuotes = !inQuotes; + i = end; + } + } else { + current += "\""; + i = end; + } + } else { + current += "\\".repeat(count); + i = end - 1; + } + continue; + } + + if (ch === "\"") { + if (inQuotes && input[i + 1] === "\"") { + current += "\""; + i += 1; + } else { + inQuotes = !inQuotes; + } + continue; + } + + if (!inQuotes && /\s/.test(ch)) { + if (current.length) { + out.push(current); + current = ""; + } + continue; + } + + current += ch; + } + + if (inQuotes) throw new Error("Unclosed quote in command line"); + if (current.length) out.push(current); + return out; +} diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 8159824dd..f48d5ac37 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -1,4 +1,5 @@ -import type { AgentChatEventEnvelope } from "./chat"; +import type { AgentChatEventEnvelope, AgentChatPermissionMode } from "./chat"; +import type { TerminalSessionSummary } from "./sessions"; export type SyncScalarBytes = { type: "bytes"; @@ -500,6 +501,25 @@ export type SyncRunQuickCommandArgs = { tracked?: boolean; }; +export type SyncCliLaunchProvider = "claude" | "codex" | "cursor" | "droid" | "opencode" | "shell"; + +export type SyncStartCliSessionArgs = { + laneId: string; + provider: SyncCliLaunchProvider; + permissionMode?: AgentChatPermissionMode | null; + title?: string | null; + initialInput?: string | null; + cols?: number; + rows?: number; + resumeSessionId?: string | null; +}; + +export type SyncStartCliSessionResult = { + sessionId: string; + ptyId: string | null; + session: TerminalSessionSummary | null; +}; + export type SyncRemoteCommandAction = | "lanes.list" | "lanes.presence.announce" @@ -538,6 +558,7 @@ export type SyncRemoteCommandAction = | "work.listSessions" | "work.updateSessionMeta" | "work.runQuickCommand" + | "work.startCliSession" | "work.closeSession" | "processes.listDefinitions" | "processes.listRuntime" diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 797cde8a7..2c9eb7b55 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -191,6 +191,7 @@ E10000000000000000000044 /* WorkSlashCommandsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000044 /* WorkSlashCommandsSheet.swift */; }; E10000000000000000000045 /* WorkSelectionActionBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000045 /* WorkSelectionActionBar.swift */; }; E10000000000000000000046 /* WorkRootScreen+Selection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000046 /* WorkRootScreen+Selection.swift */; }; + E10000000000000000000048 /* WorkTerminalEmulatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000048 /* WorkTerminalEmulatorView.swift */; }; E689F42D41A500BB8CA233E4 /* LanesTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9270CF8A67F3FA79089F39C1 /* LanesTabView.swift */; }; F2A1C9D8456E7B3C1D2E4F90 /* FilesCodeSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F9B21C0E4A6D8F1B3C5A77 /* FilesCodeSupport.swift */; }; FBEEF09EFB4911FEAC6A7E87 /* RemoteModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 483C5F1818BAE74B19B84617 /* RemoteModels.swift */; }; @@ -292,6 +293,7 @@ D10000000000000000000044 /* WorkSlashCommandsSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkSlashCommandsSheet.swift; path = ADE/Views/Work/WorkSlashCommandsSheet.swift; sourceTree = ""; }; D10000000000000000000045 /* WorkSelectionActionBar.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkSelectionActionBar.swift; path = ADE/Views/Work/WorkSelectionActionBar.swift; sourceTree = ""; }; D10000000000000000000046 /* WorkRootScreen+Selection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "WorkRootScreen+Selection.swift"; path = "ADE/Views/Work/WorkRootScreen+Selection.swift"; sourceTree = ""; }; + D10000000000000000000048 /* WorkTerminalEmulatorView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkTerminalEmulatorView.swift; path = ADE/Views/Work/WorkTerminalEmulatorView.swift; sourceTree = ""; }; D1000000000000000000003C /* WorkSessionGrouping.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkSessionGrouping.swift; path = ADE/Views/Work/WorkSessionGrouping.swift; sourceTree = ""; }; H10000000000000000000010 /* CtoRootScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoRootScreen.swift; path = ADE/Views/Cto/CtoRootScreen.swift; sourceTree = ""; }; H10000000000000000000011 /* CtoSessionDestinationView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoSessionDestinationView.swift; path = ADE/Views/Cto/CtoSessionDestinationView.swift; sourceTree = ""; }; @@ -612,6 +614,7 @@ D10000000000000000000045 /* WorkSelectionActionBar.swift */, D10000000000000000000046 /* WorkRootScreen+Selection.swift */, D10000000000000000000047 /* ADEInspectable.swift */, + D10000000000000000000048 /* WorkTerminalEmulatorView.swift */, D1000000000000000000003C /* WorkSessionGrouping.swift */, D1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift */, D1000000000000000000002D /* WorkChatRichCardViews.swift */, @@ -1109,6 +1112,7 @@ E10000000000000000000044 /* WorkSlashCommandsSheet.swift in Sources */, E10000000000000000000045 /* WorkSelectionActionBar.swift in Sources */, E10000000000000000000046 /* WorkRootScreen+Selection.swift in Sources */, + E10000000000000000000048 /* WorkTerminalEmulatorView.swift in Sources */, C10000000000000000000003 /* ADEStreamingShimmer.swift in Sources */, E1000000000000000000003C /* WorkSessionGrouping.swift in Sources */, E1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift in Sources */, diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 87abff793..1062370ac 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -2798,6 +2798,12 @@ struct TerminalSnapshot: Codable, Equatable { var capturedAt: String } +struct StartCliSessionResult: Codable, Equatable { + var sessionId: String + var ptyId: String? + var session: TerminalSessionSummary? +} + struct SyncScalarBytes: Codable, Equatable { var type: String var base64: String diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 398efd2ef..f6470ceef 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -228,10 +228,10 @@ enum SyncRequestTimeout { } } -private let syncTerminalSubscriptionMaxBytes = 80_000 +private let syncTerminalSubscriptionMaxBytes = 240_000 private let syncChatSubscriptionMaxBytes = 2_000_000 private let syncReducedLoadChatSubscriptionMaxBytes = 160_000 -private let syncTerminalBufferMaxCharacters = 80_000 +private let syncTerminalBufferMaxCharacters = 240_000 private let chatEventHistoryMaxEvents = 1_000 enum SyncBonjourTiming { @@ -744,6 +744,7 @@ final class SyncService: ObservableObject { @Published private(set) var prefersReducedSyncLoad = false @Published private(set) var terminalBufferRevision = 0 @Published private(set) var chatEventNotificationRevision = 0 + @Published private(set) var subscribedTerminalSessionIds: Set = [] @Published private(set) var subscribedChatSessionIds: Set = [] @Published private(set) var pendingOperationCount = 0 @Published private(set) var localStateRevision = 0 @@ -2342,6 +2343,7 @@ final class SyncService: ObservableObject { saveProfile(nil) saveRemoteCommandDescriptors([]) resetChatEventState(clearHistory: true) + resetTerminalSubscriptionState(clearHistory: true) activeHostProfile = nil hostName = nil } @@ -2926,18 +2928,37 @@ final class SyncService: ObservableObject { } func subscribeTerminal(sessionId: String) async throws { + let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSessionId.isEmpty else { return } + if subscribedTerminalSessionIds.contains(trimmedSessionId), terminalBuffers[trimmedSessionId] != nil { + return + } + subscribedTerminalSessionIds.insert(trimmedSessionId) let requestId = makeRequestId() let raw = try await awaitResponse(requestId: requestId) { self.sendEnvelope(type: "terminal_subscribe", requestId: requestId, payload: [ - "sessionId": sessionId, + "sessionId": trimmedSessionId, "maxBytes": syncTerminalSubscriptionMaxBytes, ]) } let snapshot = try decode(raw, as: TerminalSnapshot.self) - terminalBuffers[sessionId] = trimmedTerminalBuffer(snapshot.transcript) + guard subscribedTerminalSessionIds.contains(trimmedSessionId) else { return } + terminalBuffers[trimmedSessionId] = trimmedTerminalBuffer(snapshot.transcript) markTerminalBufferChanged(immediate: true) } + func unsubscribeTerminal(sessionId: String) async throws { + let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSessionId.isEmpty else { return } + guard subscribedTerminalSessionIds.contains(trimmedSessionId) else { return } + subscribedTerminalSessionIds.remove(trimmedSessionId) + if canSendLiveRequests() { + sendEnvelope(type: "terminal_unsubscribe", requestId: nil, payload: [ + "sessionId": trimmedSessionId, + ]) + } + } + /// Forward keystrokes (or pasted text, or control sequences) from the /// mobile UI into the live PTY for `sessionId`. Fire-and-forget — the /// host echoes accepted bytes back as `terminal_data` so the user sees @@ -3024,6 +3045,41 @@ final class SyncService: ObservableObject { _ = try await sendCommand(action: "work.runQuickCommand", args: args) } + func startCliSession( + laneId: String, + provider: String, + permissionMode: String? = nil, + title: String? = nil, + initialInput: String? = nil, + cols: Int? = nil, + rows: Int? = nil, + resumeSessionId: String? = nil + ) async throws -> StartCliSessionResult { + var args: [String: Any] = [ + "laneId": laneId, + "provider": provider, + ] + if let permissionMode, !permissionMode.isEmpty { + args["permissionMode"] = permissionMode + } + if let title, !title.isEmpty { + args["title"] = title + } + if let initialInput, !initialInput.isEmpty { + args["initialInput"] = initialInput + } + if let cols, cols > 0 { + args["cols"] = cols + } + if let rows, rows > 0 { + args["rows"] = rows + } + if let resumeSessionId, !resumeSessionId.isEmpty { + args["resumeSessionId"] = resumeSessionId + } + return try await sendDecodableCommand(action: "work.startCliSession", args: args, as: StartCliSessionResult.self) + } + func closeWorkSession(sessionId: String) async throws { _ = try await sendCommand(action: "work.closeSession", args: ["sessionId": sessionId]) } @@ -5509,6 +5565,7 @@ final class SyncService: ObservableObject { ) startRelayLoop() startInitialHydrationTask(for: connectionGeneration) + restoreTerminalSubscriptions() restoreChatEventSubscriptions() } @@ -5725,11 +5782,13 @@ final class SyncService: ObservableObject { } case "terminal_data": if let dict = payload as? [String: Any], let sessionId = dict["sessionId"] as? String, let chunk = dict["data"] as? String { + guard subscribedTerminalSessionIds.contains(sessionId) else { break } terminalBuffers[sessionId] = trimmedTerminalBuffer((terminalBuffers[sessionId] ?? "") + chunk) markTerminalBufferChanged() } case "terminal_exit": if let dict = payload as? [String: Any], let sessionId = dict["sessionId"] as? String { + guard subscribedTerminalSessionIds.contains(sessionId) else { break } let exitCode = dict["exitCode"] as? Int terminalBuffers[sessionId] = trimmedTerminalBuffer((terminalBuffers[sessionId] ?? "") + "\n\n[process exited\(exitCode.map { " with \($0)" } ?? "")]") markTerminalBufferChanged(immediate: true) @@ -6336,9 +6395,12 @@ final class SyncService: ObservableObject { } private func chatSubscriptionPayload(sessionId: String) -> [String: Any] { - [ + let maxBytes = canSendLiveRequests() && prefersReducedSyncLoad + ? syncReducedLoadChatSubscriptionMaxBytes + : syncChatSubscriptionMaxBytes + return [ "sessionId": sessionId, - "maxBytes": prefersReducedSyncLoad ? syncReducedLoadChatSubscriptionMaxBytes : syncChatSubscriptionMaxBytes, + "maxBytes": maxBytes, ] } @@ -6349,6 +6411,19 @@ final class SyncService: ObservableObject { } } + private func restoreTerminalSubscriptions() { + guard canSendLiveRequests() else { return } + let sessionIds = subscribedTerminalSessionIds.sorted() + guard !sessionIds.isEmpty else { return } + Task { @MainActor [weak self] in + guard let self else { return } + for sessionId in sessionIds { + self.subscribedTerminalSessionIds.remove(sessionId) + try? await self.subscribeTerminal(sessionId: sessionId) + } + } + } + func recordChatEventEnvelope(_ envelope: AgentChatEventEnvelope) { var events = chatEventEnvelopesBySession[envelope.sessionId] ?? [] guard !events.contains(where: { $0.id == envelope.id }) else { return } @@ -6476,6 +6551,14 @@ final class SyncService: ObservableObject { localStateRevision += 1 } + private func resetTerminalSubscriptionState(clearHistory: Bool) { + subscribedTerminalSessionIds.removeAll() + if clearHistory { + terminalBuffers.removeAll() + } + terminalBufferRevision += 1 + } + private func performInitialHydration(for connectionGeneration: UInt64) async { guard isCurrentConnectionGeneration(connectionGeneration), connectionState == .connected || connectionState == .syncing diff --git a/apps/ios/ADE/Views/Lanes/LaneTypes.swift b/apps/ios/ADE/Views/Lanes/LaneTypes.swift index 1caa1b579..4da4b6c7f 100644 --- a/apps/ios/ADE/Views/Lanes/LaneTypes.swift +++ b/apps/ios/ADE/Views/Lanes/LaneTypes.swift @@ -136,8 +136,8 @@ enum LaneFileConfirmation: Identifiable { switch self { case .discardUnstaged: return "Discard changes?" case .discardAllUnstaged: return "Discard all unstaged changes?" - case .restoreStaged: return "Discard staged changes?" - case .restoreAllStaged: return "Discard all staged changes?" + case .restoreStaged: return "Restore staged file?" + case .restoreAllStaged: return "Restore all staged files?" } } @@ -158,8 +158,8 @@ enum LaneFileConfirmation: Identifiable { switch self { case .discardUnstaged: return "Discard" case .discardAllUnstaged: return "Discard all" - case .restoreStaged: return "Discard" - case .restoreAllStaged: return "Discard all" + case .restoreStaged: return "Restore" + case .restoreAllStaged: return "Restore all" } } diff --git a/apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift b/apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift index 61aa79b7f..a8aa8e06d 100644 --- a/apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift +++ b/apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift @@ -122,14 +122,9 @@ struct WorkArtifactView: View { } } -/// Terminal session view. Subscribes to the host PTY for `session.id`, -/// streams output into a monospaced buffer, and offers a one-shot input bar -/// that forwards typed lines back to the host as `terminal_input`. -/// -/// Future work (tracked in PTY foundation): swap this replay-backed SwiftUI -/// view for a real terminal emulator (SwiftTerm) so we can render alt-screen -/// apps (vim, htop, fzf). Today we replay the common cursor/erase sequences -/// and preserve SGR colours/bold so agent CLIs stay readable on iPhone. +/// Terminal session view. Subscribes to the host PTY for `session.id`, streams +/// output through the terminal emulator, and forwards typed bytes back to the +/// host as `terminal_input`. struct WorkTerminalSessionView: View { @EnvironmentObject var syncService: SyncService let session: TerminalSessionSummary @@ -144,22 +139,13 @@ struct WorkTerminalSessionView: View { @State private var inputBuffer = "" @FocusState private var inputFocused: Bool @State private var sendingFeedback = 0 - @State private var lastSentTerminalSize: TerminalViewportSize? + @State private var lastSentTerminalSize: WorkTerminalViewport? + @State private var currentTerminalViewport: WorkTerminalViewport? private var rawBuffer: String { syncService.terminalBuffers[session.id] ?? session.lastOutputPreview ?? "" } - var terminalDisplay: WorkTerminalDisplay { - workTerminalDisplay(raw: rawBuffer, fallback: nil) - } - - private var renderedText: AttributedString { - terminalDisplay.attributedText.characters.isEmpty - ? workTerminalPlainAttributedString(" ") - : terminalDisplay.attributedText - } - private var canSendInput: Bool { syncService.connectionState == .connected || syncService.connectionState == .syncing } @@ -169,28 +155,13 @@ struct WorkTerminalSessionView: View { laneStrip GeometryReader { proxy in - ScrollView([.horizontal, .vertical]) { - Text(renderedText) - .textSelection(.enabled) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .fixedSize(horizontal: true, vertical: false) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } + WorkTerminalEmulatorView( + rawText: rawBuffer, + revision: syncService.terminalBufferRevision, + onViewportChange: handleTerminalViewportChange + ) + .frame(width: proxy.size.width, height: proxy.size.height) .background(Color.black) - .scrollIndicators(.visible) - .overlay(alignment: .topTrailing) { - if terminalDisplay.truncated { - Image(systemName: "text.line.last.and.arrowtriangle.forward") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(ADEColor.textMuted) - .padding(6) - .accessibilityLabel("Older output truncated for performance") - } - } - .onAppear { sendTerminalResize(for: proxy.size) } - .onChange(of: proxy.size) { _, newSize in sendTerminalResize(for: newSize) } - .onChange(of: syncService.connectionState) { _, _ in sendTerminalResize(for: proxy.size) } } terminalKeyBar @@ -202,6 +173,17 @@ struct WorkTerminalSessionView: View { .task { try? await syncService.subscribeTerminal(sessionId: session.id) } + .onDisappear { + Task { + try? await syncService.unsubscribeTerminal(sessionId: session.id) + } + } + .onChange(of: syncService.connectionState) { _, _ in + lastSentTerminalSize = nil + if let currentTerminalViewport { + sendTerminalResize(currentTerminalViewport) + } + } } /// Slim, transparent context strip — no material backplate, no padding bloat. @@ -351,29 +333,19 @@ struct WorkTerminalSessionView: View { inputFocused = true } - private func sendTerminalResize(for size: CGSize) { + private func handleTerminalViewportChange(_ viewport: WorkTerminalViewport) { + currentTerminalViewport = viewport + sendTerminalResize(viewport) + } + + private func sendTerminalResize(_ viewport: WorkTerminalViewport) { guard canSendInput else { return } - let viewport = TerminalViewportSize(size: size) guard viewport != lastSentTerminalSize else { return } lastSentTerminalSize = viewport syncService.sendTerminalResize(sessionId: session.id, cols: viewport.cols, rows: viewport.rows) } } -private struct TerminalViewportSize: Equatable { - let cols: Int - let rows: Int - - init(size: CGSize) { - // Matches the 12pt monospaced transcript text closely enough for PTY - // reflow until this screen hosts a full terminal emulator. - let contentWidth = max(0, size.width - 28) - let contentHeight = max(0, size.height - 24) - cols = max(20, min(240, Int(floor(contentWidth / 7.2)))) - rows = max(4, min(80, Int(floor(contentHeight / 15.0)))) - } -} - struct WorkFullscreenImageView: View { @Environment(\.dismiss) var dismiss let image: WorkFullscreenImage diff --git a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift index 4d79e0f3e..9aa434baa 100644 --- a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift +++ b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift @@ -584,7 +584,7 @@ func workKnownModelDisplayName(_ raw: String?) -> String? { case "gpt-5.4", "gpt-5.4-codex", "openai/gpt-5.4", "openai/gpt-5.4-codex": return "GPT-5.4" case "gpt-5.4-mini", "gpt-5.4-mini-codex", "openai/gpt-5.4-mini", "openai/gpt-5.4-mini-codex": - return "GPT-5.4-Mini" + return "GPT 5.4 Mini" case "gpt-5.3-codex", "openai/gpt-5.3-codex": return "GPT-5.3-Codex" case "gpt-5.3-codex-spark", "gpt-5.3-spark", "codex-spark", "spark", "openai/gpt-5.3-codex-spark": diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index e523c506d..008aa4753 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -1,5 +1,33 @@ import SwiftUI +enum WorkNewSessionMode: String, CaseIterable, Identifiable { + case chat + case cli + + var id: String { rawValue } + + var title: String { + switch self { + case .chat: return "ADE chat" + case .cli: return "CLI session" + } + } +} + +struct WorkCliProviderOption: Identifiable, Hashable { + let id: String + let title: String +} + +private let workCliProviderOptions: [WorkCliProviderOption] = [ + WorkCliProviderOption(id: "claude", title: "Claude Code"), + WorkCliProviderOption(id: "codex", title: "Codex"), + WorkCliProviderOption(id: "cursor", title: "Cursor Agent CLI"), + WorkCliProviderOption(id: "opencode", title: "OpenCode CLI"), + WorkCliProviderOption(id: "droid", title: "Factory Droid CLI"), + WorkCliProviderOption(id: "shell", title: "Shell"), +] + /// Full-screen "Start a new conversation" composer that replaces the modal /// WorkNewChatSheet. Mirrors the desktop welcome screen: big ADE word-mark, /// one-line tagline, a minimal workspace pill users can change inline, and a @@ -14,6 +42,7 @@ struct WorkNewChatScreen: View { let lanes: [LaneSummary] let preferredLaneId: String? let onStarted: @MainActor (AgentChatSessionSummary, String) async -> Void + let onCliStarted: @MainActor (TerminalSessionSummary) async -> Void let onRefreshLanes: @MainActor () async -> Void @State private var selectedLaneId: String = "" @@ -24,6 +53,7 @@ struct WorkNewChatScreen: View { @State private var modelPickerPresented = false @State private var runtimeMode: String = "default" @State private var reasoningEffort: String = "" + @State private var sessionMode: WorkNewSessionMode = .chat private var selectedLaneName: String { if let match = lanes.first(where: { $0.id == selectedLaneId }) { @@ -51,6 +81,7 @@ struct WorkNewChatScreen: View { } laneSelector + modeSelector } .padding(.horizontal, 20) .padding(.vertical, 16) @@ -94,6 +125,12 @@ struct WorkNewChatScreen: View { reasoningEffort = "" } } + .onChange(of: sessionMode) { _, newMode in + if newMode == .chat && !["claude", "codex", "cursor", "opencode"].contains(provider) { + provider = "claude" + modelId = "claude-sonnet-4-6" + } + } .onChange(of: modelId) { _, newModel in if !modelSupportsReasoning(modelId: newModel, provider: provider) { reasoningEffort = "" @@ -185,14 +222,27 @@ struct WorkNewChatScreen: View { .buttonStyle(.plain) } + @ViewBuilder + private var modeSelector: some View { + Picker("Session type", selection: $sessionMode) { + ForEach(WorkNewSessionMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 8) + .accessibilityLabel("Session type") + } + @ViewBuilder private var composerBar: some View { WorkNewChatComposerBar( - provider: provider, + sessionMode: sessionMode, + provider: $provider, modelId: modelId, modelName: prettyNewChatModelName(modelId), busy: busy, - canStart: !busy && !selectedLaneId.isEmpty && !modelId.isEmpty, + canStart: !busy && !selectedLaneId.isEmpty && (sessionMode == .cli || !modelId.isEmpty), runtimeMode: $runtimeMode, reasoningEffort: $reasoningEffort, onOpenModelPicker: { modelPickerPresented = true }, @@ -229,12 +279,58 @@ struct WorkNewChatScreen: View { @MainActor private func submit(openingMessage: String) async -> Bool { let opener = openingMessage.trimmingCharacters(in: .whitespacesAndNewlines) - guard !busy && !opener.isEmpty && !selectedLaneId.isEmpty && !modelId.isEmpty else { return false } + guard !busy && !selectedLaneId.isEmpty else { return false } + if sessionMode == .chat { + guard !opener.isEmpty && !modelId.isEmpty else { return false } + } busy = true errorMessage = nil let wire = workRuntimeWireFields(provider: provider, mode: runtimeMode) let normalizedReasoning = reasoningEffort.trimmingCharacters(in: .whitespacesAndNewlines) do { + if sessionMode == .cli { + let result = try await syncService.startCliSession( + laneId: selectedLaneId, + provider: provider, + permissionMode: wire.permissionMode ?? (runtimeMode.isEmpty ? nil : runtimeMode), + title: workCliProviderOptions.first(where: { $0.id == provider })?.title, + initialInput: opener.isEmpty ? nil : opener, + cols: 88, + rows: 28 + ) + if let session = result.session { + await onCliStarted(session) + } else { + let lane = lanes.first(where: { $0.id == selectedLaneId }) + await onCliStarted(TerminalSessionSummary( + id: result.sessionId, + laneId: selectedLaneId, + laneName: lane?.name ?? selectedLaneId, + ptyId: result.ptyId, + tracked: true, + pinned: false, + manuallyNamed: nil, + goal: nil, + toolType: provider == "shell" ? "shell" : (provider == "cursor" ? "cursor-cli" : provider), + title: workCliProviderOptions.first(where: { $0.id == provider })?.title ?? providerLabel(provider), + status: "running", + startedAt: workDateFormatter.string(from: Date()), + endedAt: nil, + exitCode: nil, + transcriptPath: "", + headShaStart: nil, + headShaEnd: nil, + lastOutputPreview: nil, + summary: nil, + runtimeState: "running", + resumeCommand: nil, + resumeMetadata: nil, + chatIdleSinceAt: nil + )) + } + busy = false + return true + } let summary = try await syncService.createChatSession( laneId: selectedLaneId, provider: provider, @@ -261,7 +357,8 @@ struct WorkNewChatScreen: View { } private struct WorkNewChatComposerBar: View { - let provider: String + let sessionMode: WorkNewSessionMode + @Binding var provider: String let modelId: String let modelName: String let busy: Bool @@ -279,7 +376,7 @@ private struct WorkNewChatComposerBar: View { } private var canSend: Bool { - canStart && !trimmedDraft.isEmpty + canStart && (sessionMode == .cli || !trimmedDraft.isEmpty) } private var runtimeOptions: [WorkRuntimeModeOption] { @@ -294,9 +391,24 @@ private struct WorkNewChatComposerBar: View { workRuntimeModeTint(runtimeMode) } + private var acceptsOpeningMessage: Bool { + !(sessionMode == .cli && provider == "shell") + } + + private var placeholder: String { + if sessionMode == .cli && provider == "shell" { + return "Shell starts empty; type after it opens" + } + return sessionMode == .cli ? "Optional first instruction…" : "Type to vibecode…" + } + + private var sendLabel: String { + sessionMode == .cli ? "Start" : "Send" + } + var body: some View { VStack(alignment: .leading, spacing: 12) { - TextField("Type to vibecode…", text: $draft, axis: .vertical) + TextField(placeholder, text: $draft, axis: .vertical) .textFieldStyle(.plain) .lineLimit(1...6) .font(.body) @@ -306,46 +418,52 @@ private struct WorkNewChatComposerBar: View { .textInputAutocapitalization(.sentences) .focused($composerFocused) .frame(maxWidth: .infinity, minHeight: 28, alignment: .leading) + .disabled(!acceptsOpeningMessage) + .opacity(acceptsOpeningMessage ? 1 : 0.62) HStack(alignment: .center, spacing: 8) { ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .center, spacing: 10) { - Button { - onOpenModelPicker() - } label: { - HStack(spacing: 6) { - WorkProviderLogo( - provider: provider, - fallbackSymbol: providerIcon(provider), - tint: providerTint(provider), - size: 16 - ) - Text(modelName) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - if !reasoningEffort.isEmpty { - Text("·") - .font(.caption2) - .foregroundStyle(ADEColor.textMuted.opacity(0.5)) - Text(reasoningEffort.capitalized) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(ADEColor.textMuted) + if sessionMode == .chat { + Button { + onOpenModelPicker() + } label: { + HStack(spacing: 6) { + WorkProviderLogo( + provider: provider, + fallbackSymbol: providerIcon(provider), + tint: providerTint(provider), + size: 16 + ) + Text(modelName) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) .lineLimit(1) + if !reasoningEffort.isEmpty { + Text("·") + .font(.caption2) + .foregroundStyle(ADEColor.textMuted.opacity(0.5)) + Text(reasoningEffort.capitalized) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(ADEColor.textMuted) } - Image(systemName: "chevron.down") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(ADEColor.textMuted) + .padding(.horizontal, 9) + .padding(.vertical, 6) + .background(Color.clear, in: Capsule(style: .continuous)) + .overlay( + Capsule(style: .continuous) + .stroke(ADEColor.border.opacity(0.22), lineWidth: 0.5) + ) } - .padding(.horizontal, 9) - .padding(.vertical, 6) - .background(Color.clear, in: Capsule(style: .continuous)) - .overlay( - Capsule(style: .continuous) - .stroke(ADEColor.border.opacity(0.22), lineWidth: 0.5) - ) + .buttonStyle(.plain) + } else { + cliProviderMenu } - .buttonStyle(.plain) if !runtimeOptions.isEmpty { Menu { @@ -388,7 +506,7 @@ private struct WorkNewChatComposerBar: View { } Button { - let text = trimmedDraft + let text = acceptsOpeningMessage ? trimmedDraft : "" draft = "" Task { let started = await onSubmit(text) @@ -406,7 +524,7 @@ private struct WorkNewChatComposerBar: View { Image(systemName: "paperplane.fill") .font(.system(size: 12, weight: .bold)) } - Text("Send") + Text(sendLabel) .font(.caption.weight(.semibold)) } .foregroundStyle(canSend ? Color.white : ADEColor.textSecondary) @@ -424,7 +542,7 @@ private struct WorkNewChatComposerBar: View { } .buttonStyle(.plain) .disabled(!canSend) - .accessibilityLabel(canSend ? "Start chat" : "Enter a message to start") + .accessibilityLabel(canSend ? (sessionMode == .cli ? "Start CLI session" : "Start chat") : "Enter a message to start") } } .padding(.horizontal, 14) @@ -452,6 +570,54 @@ private struct WorkNewChatComposerBar: View { .shadow(color: Color.black.opacity(0.32), radius: 14, y: 6) .padding(.horizontal, 16) .padding(.bottom, 0) + .onChange(of: provider) { _, _ in + if !acceptsOpeningMessage { draft = "" } + } + .onChange(of: sessionMode) { _, _ in + if !acceptsOpeningMessage { draft = "" } + } + } + + private var cliProviderMenu: some View { + Menu { + ForEach(workCliProviderOptions) { option in + Button { + provider = option.id + } label: { + if option.id == provider { + Label(option.title, systemImage: "checkmark") + } else { + Text(option.title) + } + } + } + } label: { + HStack(spacing: 6) { + WorkProviderLogo( + provider: provider, + fallbackSymbol: provider == "shell" ? "terminal.fill" : providerIcon(provider), + tint: providerTint(provider), + size: 16 + ) + Text(workCliProviderOptions.first(where: { $0.id == provider })?.title ?? providerLabel(provider)) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(ADEColor.textMuted) + } + .padding(.horizontal, 9) + .padding(.vertical, 6) + .background(Color.clear, in: Capsule(style: .continuous)) + .overlay( + Capsule(style: .continuous) + .stroke(ADEColor.border.opacity(0.22), lineWidth: 0.5) + ) + } + .menuStyle(.borderlessButton) + .buttonStyle(.plain) + .accessibilityLabel("CLI provider: \(workCliProviderOptions.first(where: { $0.id == provider })?.title ?? provider). Tap to change.") } } diff --git a/apps/ios/ADE/Views/Work/WorkPreviews.swift b/apps/ios/ADE/Views/Work/WorkPreviews.swift index 377bc5de6..bb8de81c2 100644 --- a/apps/ios/ADE/Views/Work/WorkPreviews.swift +++ b/apps/ios/ADE/Views/Work/WorkPreviews.swift @@ -471,6 +471,7 @@ private enum WorkPreviewData { lanes: [WorkPreviewData.lane], preferredLaneId: WorkPreviewData.lane.id, onStarted: { _, _ in }, + onCliStarted: { _ in }, onRefreshLanes: {} ) .environmentObject(WorkPreviewData.syncService) diff --git a/apps/ios/ADE/Views/Work/WorkRootComponents.swift b/apps/ios/ADE/Views/Work/WorkRootComponents.swift index 4d46d1c18..1779aa02c 100644 --- a/apps/ios/ADE/Views/Work/WorkRootComponents.swift +++ b/apps/ios/ADE/Views/Work/WorkRootComponents.swift @@ -561,7 +561,7 @@ struct WorkSessionListRow: View { private var shouldShowResumeAction: Bool { guard !isChatSession(session) else { return false } - return status == "idle" || status == "ended" + return (status == "idle" || status == "ended") && terminalSessionHasResumeTarget(session) } } diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift index b68397106..50db7abe8 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift @@ -329,7 +329,29 @@ extension WorkRootScreen { if archivedSessionIds.contains(session.id) { toggleArchive(session) } - openSession(session) + guard !isChatSession(session), session.status != "running", terminalSessionHasResumeTarget(session) else { + openSession(session) + return + } + Task { + do { + let provider = cliProviderForTerminalSession(session) + let result = try await syncService.startCliSession( + laneId: session.laneId, + provider: provider, + permissionMode: session.resumeMetadata?.launch.permissionMode ?? session.resumeMetadata?.permissionMode, + title: session.title, + resumeSessionId: session.id + ) + if let resumed = result.session { + optimisticSessions[resumed.id] = resumed + } + openSession(session) + await reload(refreshRemote: true) + } catch { + errorMessage = error.localizedDescription + } + } } func deleteChatSession(_ session: TerminalSessionSummary) { @@ -396,6 +418,19 @@ extension WorkRootScreen { } +private func cliProviderForTerminalSession(_ session: TerminalSessionSummary) -> String { + if let provider = session.resumeMetadata?.provider, !provider.isEmpty { + return provider + } + let toolType = (session.toolType ?? "").lowercased() + if toolType.hasPrefix("claude") { return "claude" } + if toolType.hasPrefix("codex") { return "codex" } + if toolType.hasPrefix("cursor") { return "cursor" } + if toolType.hasPrefix("droid") { return "droid" } + if toolType.hasPrefix("opencode") { return "opencode" } + return "shell" +} + /// Lower rank = higher priority. Selected lane > priority lanes (live / /// awaiting-input) > everything else, so reduced-mode prefix(6) keeps the /// most user-visible refreshes instead of dropping them. diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index f466e324b..1632bc9b9 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -496,6 +496,17 @@ struct WorkRootScreen: View { await reload(refreshRemote: true) } }, + onCliStarted: { session in + optimisticSessions[session.id] = session + selectedSessionTransitionId = nil + var fresh = NavigationPath() + fresh.append(WorkSessionRoute(sessionId: session.id)) + await Task.yield() + path = fresh + Task { @MainActor in + await reload(refreshRemote: true) + } + }, onRefreshLanes: { await reload(refreshRemote: true) } ) .environmentObject(syncService) diff --git a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift index 4cd70625e..173da6f2e 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -20,6 +20,15 @@ func isRunOwnedSession(_ session: TerminalSessionSummary) -> Bool { .lowercased() == "run-shell" } +func terminalSessionHasResumeTarget(_ session: TerminalSessionSummary) -> Bool { + if session.resumeMetadata != nil { + return true + } + return session.resumeCommand? + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty == false +} + func defaultWorkChatTitle(provider: String) -> String { switch provider.lowercased() { case "codex": @@ -160,11 +169,10 @@ func sessionSymbol(_ session: TerminalSessionSummary, provider: String?) -> Stri func normalizedWorkChatSessionStatus(session: TerminalSessionSummary?, summary: AgentChatSessionSummary?) -> String { let raw = rawWorkChatSessionStatus(session: session, summary: summary) - // Stale-session guard: a chat that's been "awaiting-input", "active", or - // "idle" but hasn't moved in over 7 days is almost certainly never going - // to resume. Desktop drops these from its Work list; iOS now does too so - // the two devices stay in agreement. - if raw == "awaiting-input" || raw == "active" || raw == "idle" { + // Stale-session guard: a chat that's been "active" or "idle" but hasn't + // moved in over 7 days is almost certainly never going to resume. Keep + // explicit awaiting-input sessions visible until they are resolved or closed. + if raw == "active" || raw == "idle" { let lastActivityRaw = summary?.lastActivityAt ?? session?.chatIdleSinceAt ?? session?.startedAt if let last = lastActivityRaw, let date = workChatLastActivityDate(last), diff --git a/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift b/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift new file mode 100644 index 000000000..af2399342 --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift @@ -0,0 +1,423 @@ +import SwiftUI +import UIKit + +struct WorkTerminalViewport: Equatable { + let cols: Int + let rows: Int +} + +struct WorkTerminalEmulatorView: UIViewRepresentable { + let rawText: String + let revision: Int + let onViewportChange: (WorkTerminalViewport) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onViewportChange: onViewportChange) + } + + func makeUIView(context: Context) -> ADETerminalTextView { + let view = ADETerminalTextView() + view.onViewportChange = { viewport in + context.coordinator.updateViewport(viewport, rawText: rawText, view: view) + } + return view + } + + func updateUIView(_ view: ADETerminalTextView, context: Context) { + view.onViewportChange = { viewport in + context.coordinator.onViewportChange = onViewportChange + context.coordinator.updateViewport(viewport, rawText: rawText, view: view) + } + context.coordinator.onViewportChange = onViewportChange + context.coordinator.render(rawText: rawText, revision: revision, in: view) + } + + final class Coordinator { + var onViewportChange: (WorkTerminalViewport) -> Void + private var screen = WorkTerminalScreen() + private var lastRawText = "" + private var lastRevision = -1 + private var lastViewport: WorkTerminalViewport? + + init(onViewportChange: @escaping (WorkTerminalViewport) -> Void) { + self.onViewportChange = onViewportChange + } + + func updateViewport(_ viewport: WorkTerminalViewport, rawText: String, view: ADETerminalTextView) { + guard viewport != lastViewport else { return } + lastViewport = viewport + screen.resize(cols: viewport.cols) + screen.reset() + screen.write(rawText) + lastRawText = rawText + view.render(screen.attributedString(font: view.terminalFont)) + onViewportChange(viewport) + } + + func render(rawText: String, revision: Int, in view: ADETerminalTextView) { + guard revision != lastRevision || rawText != lastRawText else { return } + defer { + lastRevision = revision + lastRawText = rawText + } + + if rawText.hasPrefix(lastRawText) { + let delta = String(rawText.dropFirst(lastRawText.count)) + screen.write(delta) + } else { + screen.reset() + screen.write(rawText) + } + view.render(screen.attributedString(font: view.terminalFont)) + } + } +} + +final class ADETerminalTextView: UIView { + let terminalFont = UIFont.monospacedSystemFont(ofSize: 11.5, weight: .regular) + var onViewportChange: ((WorkTerminalViewport) -> Void)? + + private let textView = UITextView() + private var lastViewport: WorkTerminalViewport? + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .black + textView.translatesAutoresizingMaskIntoConstraints = false + textView.backgroundColor = .black + textView.textColor = .white + textView.tintColor = .white + textView.font = terminalFont + textView.isEditable = false + textView.isSelectable = true + textView.isScrollEnabled = true + textView.alwaysBounceVertical = true + textView.showsVerticalScrollIndicator = true + textView.showsHorizontalScrollIndicator = false + textView.textContainerInset = UIEdgeInsets(top: 8, left: 10, bottom: 8, right: 10) + textView.textContainer.lineFragmentPadding = 0 + textView.textContainer.widthTracksTextView = true + textView.autocorrectionType = .no + textView.autocapitalizationType = .none + addSubview(textView) + NSLayoutConstraint.activate([ + textView.leadingAnchor.constraint(equalTo: leadingAnchor), + textView.trailingAnchor.constraint(equalTo: trailingAnchor), + textView.topAnchor.constraint(equalTo: topAnchor), + textView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + required init?(coder: NSCoder) { + return nil + } + + override func layoutSubviews() { + super.layoutSubviews() + publishViewportIfNeeded() + } + + func render(_ attributed: NSAttributedString) { + let nearBottom = textView.contentOffset.y + textView.bounds.height >= textView.contentSize.height - 80 + textView.attributedText = attributed + if nearBottom { + let bottomY = max(-textView.adjustedContentInset.top, textView.contentSize.height - textView.bounds.height + textView.adjustedContentInset.bottom) + textView.setContentOffset(CGPoint(x: 0, y: bottomY), animated: false) + } + publishViewportIfNeeded() + } + + private func publishViewportIfNeeded() { + guard bounds.width > 1, bounds.height > 1 else { return } + let charSize = ("W" as NSString).size(withAttributes: [.font: terminalFont]) + let usableWidth = max(1, bounds.width - textView.textContainerInset.left - textView.textContainerInset.right) + let usableHeight = max(1, bounds.height - textView.textContainerInset.top - textView.textContainerInset.bottom) + let viewport = WorkTerminalViewport( + cols: max(20, min(240, Int(floor(usableWidth / max(1, charSize.width))))), + rows: max(4, min(120, Int(floor(usableHeight / max(1, terminalFont.lineHeight))))) + ) + guard viewport != lastViewport else { return } + lastViewport = viewport + onViewportChange?(viewport) + } +} + +private struct WorkTerminalCell { + var scalar: UnicodeScalar + var foreground: UIColor? + var bold: Bool +} + +private final class WorkTerminalScreen { + private var lines: [[WorkTerminalCell]] = [[]] + private var row = 0 + private var column = 0 + private var cols = 88 + private var foreground: UIColor? + private var bold = false + private let maxLines = 4_000 + + func resize(cols: Int) { + self.cols = max(20, min(240, cols)) + column = min(column, self.cols - 1) + } + + func reset() { + lines = [[]] + row = 0 + column = 0 + foreground = nil + bold = false + } + + func write(_ input: String) { + let scalars = Array(input.unicodeScalars) + var index = scalars.startIndex + while index < scalars.endIndex { + let scalar = scalars[index] + index = scalars.index(after: index) + + if scalar == "\u{001B}" { + consumeEscape(in: scalars, index: &index) + continue + } + + switch scalar { + case "\n": + newline() + case "\r": + column = 0 + case "\t": + let spaces = max(1, 4 - (column % 4)) + for _ in 0..= 0x20 && scalar.value != 0x7F { + put(scalar) + } + } + } + trimLinesIfNeeded() + } + + func attributedString(font: UIFont) -> NSAttributedString { + let result = NSMutableAttributedString(string: "") + let baseParagraph = NSMutableParagraphStyle() + baseParagraph.lineBreakMode = .byClipping + baseParagraph.lineSpacing = 0 + let baseAttrs: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: UIColor(white: 0.92, alpha: 1), + .paragraphStyle: baseParagraph, + ] + let boldFont = UIFont.monospacedSystemFont(ofSize: font.pointSize, weight: .semibold) + + for lineIndex in lines.indices { + if lineIndex > lines.startIndex { + result.append(NSAttributedString(string: "\n", attributes: baseAttrs)) + } + let line = lines[lineIndex] + guard !line.isEmpty else { continue } + var run = "" + var runColor: UIColor? + var runBold = false + func flush() { + guard !run.isEmpty else { return } + var attrs = baseAttrs + attrs[.foregroundColor] = runColor ?? UIColor(white: 0.92, alpha: 1) + attrs[.font] = runBold ? boldFont : font + result.append(NSAttributedString(string: run, attributes: attrs)) + run = "" + } + + for cell in line { + if !terminalColorsEqual(cell.foreground, runColor) || cell.bold != runBold { + flush() + runColor = cell.foreground + runBold = cell.bold + } + run.append(String(cell.scalar)) + } + flush() + } + if result.length == 0 { + result.append(NSAttributedString(string: " ", attributes: baseAttrs)) + } + return result + } + + private func put(_ scalar: UnicodeScalar) { + ensureCursor() + if column >= cols { + newline() + } + while lines[row].count < column { + lines[row].append(WorkTerminalCell(scalar: " ", foreground: foreground, bold: bold)) + } + let cell = WorkTerminalCell(scalar: scalar, foreground: foreground, bold: bold) + if column < lines[row].count { + lines[row][column] = cell + } else { + lines[row].append(cell) + } + column += 1 + } + + private func newline() { + row += 1 + column = 0 + ensureCursor() + } + + private func ensureCursor() { + while lines.count <= row { + lines.append([]) + } + } + + private func trimLinesIfNeeded() { + guard lines.count > maxLines else { return } + let overflow = lines.count - maxLines + lines.removeFirst(overflow) + row = max(0, row - overflow) + } + + private func consumeEscape(in scalars: [UnicodeScalar], index: inout Int) { + guard index < scalars.endIndex else { return } + let kind = scalars[index] + index = scalars.index(after: index) + switch kind { + case "[": + consumeCSI(in: scalars, index: &index) + case "]": + consumeOSC(in: scalars, index: &index) + case "c": + reset() + case "(", ")", "*", "+": + if index < scalars.endIndex { index = scalars.index(after: index) } + default: + break + } + } + + private func consumeOSC(in scalars: [UnicodeScalar], index: inout Int) { + while index < scalars.endIndex { + let current = scalars[index] + index = scalars.index(after: index) + if current == "\u{0007}" { break } + if current == "\u{001B}", index < scalars.endIndex, scalars[index] == "\\" { + index = scalars.index(after: index) + break + } + } + } + + private func consumeCSI(in scalars: [UnicodeScalar], index: inout Int) { + var body = String.UnicodeScalarView() + while index < scalars.endIndex { + let scalar = scalars[index] + index = scalars.index(after: index) + if scalar.value >= 0x40 && scalar.value <= 0x7E { + applyCSI(command: Character(scalar), body: String(body)) + break + } + body.append(scalar) + } + } + + private func applyCSI(command: Character, body: String) { + let cleaned = body.trimmingCharacters(in: CharacterSet(charactersIn: "?")) + let params = cleaned + .split(separator: ";", omittingEmptySubsequences: false) + .map { Int(String($0).trimmingCharacters(in: CharacterSet(charactersIn: "?"))) ?? 0 } + let first = params.first ?? 0 + + if (command == "h" || command == "l") && (body.contains("1049") || body.contains("47") || body.contains("1047")) { + reset() + return + } + + switch command { + case "A": + row = max(0, row - max(1, first)) + case "B": + row += max(1, first) + ensureCursor() + case "C": + column = min(cols - 1, column + max(1, first)) + case "D": + column = max(0, column - max(1, first)) + case "G": + column = max(0, min(cols - 1, max(1, first) - 1)) + case "H", "f": + row = max(0, max(1, first) - 1) + column = max(0, min(cols - 1, max(1, params.dropFirst().first ?? 1) - 1)) + ensureCursor() + case "J": + if first == 2 || first == 3 { reset() } + case "K": + ensureCursor() + if first == 1 { + for i in 0.. UIColor { + let normal: [UIColor] = [ + .init(white: 0.15, alpha: 1), .systemRed, .systemGreen, .systemYellow, + .systemBlue, .systemPurple, .systemTeal, .init(white: 0.86, alpha: 1), + ] + let brightColors: [UIColor] = [ + .init(white: 0.45, alpha: 1), .systemRed, .systemGreen, .systemYellow, + .systemBlue, .systemPink, .cyan, .white, + ] + let palette = bright ? brightColors : normal + return palette[max(0, min(index, palette.count - 1))] + } +} + +private func terminalColorsEqual(_ lhs: UIColor?, _ rhs: UIColor?) -> Bool { + switch (lhs, rhs) { + case (.none, .none): + return true + case (.some(let left), .some(let right)): + return left.isEqual(right) + default: + return false + } +} diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index 2b851f054..583f3f4e1 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -315,7 +315,9 @@ func collapseConsecutiveWorkToolEntries(_ entries: [WorkTimelineEntry]) -> [Work func flushCluster() { if !cluster.isEmpty { let members = cluster.compactMap(workToolGroupMember(from:)) - if members.count == cluster.count { + if cluster.count == 1 { + result.append(contentsOf: cluster) + } else if members.count == cluster.count { let anchor = cluster[0] var readOnly: [WorkToolGroupMember] = [] var codeChange: [WorkToolGroupMember] = [] diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 8d2221485..c315c9598 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -4622,6 +4622,42 @@ final class ADETests: XCTestCase { XCTAssertFalse(isChatSession(makeTerminalSessionSummary(toolType: nil))) } + func testTerminalResumeTargetDetectionMatchesDesktopResumeAvailability() { + XCTAssertFalse(terminalSessionHasResumeTarget(makeTerminalSessionSummary( + toolType: "shell", + resumeCommand: nil, + resumeMetadata: nil + ))) + XCTAssertFalse(terminalSessionHasResumeTarget(makeTerminalSessionSummary( + toolType: "run-shell", + resumeCommand: " ", + resumeMetadata: nil + ))) + XCTAssertTrue(terminalSessionHasResumeTarget(makeTerminalSessionSummary( + toolType: "codex", + resumeCommand: "codex resume thread-1", + resumeMetadata: nil + ))) + XCTAssertTrue(terminalSessionHasResumeTarget(makeTerminalSessionSummary( + toolType: "codex", + resumeCommand: nil, + resumeMetadata: TerminalResumeMetadata( + provider: "codex", + targetKind: "thread", + targetId: "thread-1", + launch: TerminalResumeLaunchConfig( + permissionMode: "edit", + claudePermissionMode: nil, + codexApprovalPolicy: "on-request", + codexSandbox: "workspace-write", + codexConfigSource: "flags" + ), + target: nil, + permissionMode: "edit" + ) + ))) + } + func testAgentChatSessionSummaryDecodesCursorAndControlFields() throws { let payload: [String: Any] = [ "sessionId": "chat-1", @@ -7050,7 +7086,9 @@ final class ADETests: XCTestCase { status: String = "running", title: String = "Codex chat", lastOutputPreview: String? = nil, - startedAt: String = recentIso8601Fixture() + startedAt: String = recentIso8601Fixture(), + resumeCommand: String? = nil, + resumeMetadata: TerminalResumeMetadata? = nil ) -> TerminalSessionSummary { TerminalSessionSummary( id: id, @@ -7073,8 +7111,8 @@ final class ADETests: XCTestCase { lastOutputPreview: lastOutputPreview, summary: nil, runtimeState: runtimeState, - resumeCommand: nil, - resumeMetadata: nil, + resumeCommand: resumeCommand, + resumeMetadata: resumeMetadata, chatIdleSinceAt: nil ) } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 39ebef129..eb9ac9fd1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -862,7 +862,13 @@ Renderer surfaces: - Bidirectional sync continues; on disconnect, exponential-backoff reconnect with version catch-up. `reconnectIfPossible` is guarded against overlapping runs. - All reads are local and scoped to the active project id — the iOS tab is instant and offline-capable after the selected project's row has hydrated. - Writes from user actions: write locally, replicate to host. Execution commands (create PR, run command) are routed to the host via the `command`/`command_ack`/`command_result` message flow. -- Sub-protocols: changeset sync, project catalog/switch, file access, terminal stream, chat stream (live `chat_event` push from host), command routing, lane presence announce/release. +- Sub-protocols: changeset sync, project catalog/switch, file access, + subscribed terminal stream/control, chat stream (live `chat_event` + push from host), command routing, and lane presence announce/release. + Command routing includes the Work CLI launcher + (`work.startCliSession`), whose provider command construction is + shared with the desktop Work tab through + `apps/desktop/src/shared/cliLaunch.ts`. - Pairing is a **user-set 6-digit PIN** stored at `.ade/secrets/sync-pin.json` on the host. The phone sends the PIN once; the host returns a durable per-device secret. QR payload is v2 (host identity + port + address candidates, no pairing code). - APNs pipeline: iOS registers device tokens (alert + push-to-start + per-activity update) via `SyncService.registerPushToken`. The host's `notificationEventBus` routes domain events (chat, PR, CTO, system) to `apnsService` for alert pushes and Live Activity update pushes, filtered by per-device `NotificationPreferences` stored in the iOS App Group `UserDefaults`. - Widgets: `ADEWorkspaceWidget` (Home Screen), `ADELockScreenWidget`, `ADEControlWidget` (Control Center, iOS 18+) read from a shared `WorkspaceSnapshot` in the App Group container. `LiveActivityCoordinator` manages the single workspace Live Activity. diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 3173323e2..b3d97797d 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -113,7 +113,10 @@ Host-side service files Protocol version is `1`. Default host port is `8787`. - `apps/desktop/src/shared/types/sync.ts` — typed protocol DTOs for `SyncEnvelope`, including controller-originated `terminal_input` and - `terminal_resize` envelopes. + `terminal_resize` envelopes, plus the mobile CLI launcher payload + (`SyncCliLaunchProvider`, `SyncStartCliSessionArgs`, + `SyncStartCliSessionResult`) consumed by the + `work.startCliSession` remote command. - `syncService.ts` (~875 lines) — orchestrator that wires host, peer, device registry, draft persistence, pin store, and exposes the IPC entry points used by the renderer Settings > Sync surface @@ -137,10 +140,17 @@ Host-side service files 6-digit pairing PIN at `.ade/secrets/sync-pin.json`, chmodded `0600`. Host never rotates the PIN; the user sets or clears it from Settings > Sync. -- `syncRemoteCommandService.ts` (~1,920 lines) — command action +- `syncRemoteCommandService.ts` (~2,030 lines) — command action registry (lanes, chat, git, PR, sessions, conflicts, files, - `prs.getMobileSnapshot`, `lanes.presence.*`). Documented separately - in `remote-commands.md`. + `prs.getMobileSnapshot`, `lanes.presence.*`, + `work.runQuickCommand`, `work.startCliSession`). The CLI launch + registry shares its provider-to-argv translation with the desktop + Work tab through `apps/desktop/src/shared/cliLaunch.ts` + (`buildTrackedCliLaunchCommand`, `buildTrackedCliResumeCommand`, + `LAUNCH_PROFILE_TOOL_TYPE`, `LAUNCH_PROFILE_TITLE`) so a phone + starting Claude/Codex/Cursor/Droid/OpenCode/shell hits the same + permission-mode flags, ADE guidance, and provider preambles the + desktop sends. Documented separately in `remote-commands.md`. Client-side (iOS) service files (`apps/ios/ADE/Services/`): @@ -151,10 +161,11 @@ Client-side (iOS) service files (`apps/ios/ADE/Services/`): fields mirrored from desktop schema. - `SyncService.swift` — WebSocket client, envelope encoding (zlib), command routing, keychain integration, PIN-based pairing, lane - presence announcements, terminal input/resize senders, PR mobile - snapshot fetch, live chat-event push listener, project home/catalog - state, active-project scoping, unregistered-worktree discovery, and - APNs push-token registration to the host. + presence announcements, terminal subscribe/unsubscribe tracking, + terminal input/resize senders, mobile CLI session launch/resume, + PR mobile snapshot fetch, live chat-event push listener, project + home/catalog state, active-project scoping, unregistered-worktree + discovery, and APNs push-token registration to the host. - `KeychainService.swift` — iOS Keychain Services for paired device secrets. - `LiveActivityCoordinator.swift` — owns the single workspace @@ -198,8 +209,9 @@ iOS notification files: - `apps/ios/ADE/Shared/ADESharedModels.swift` — `AgentSnapshot`, `PrSnapshot` shared with widget and notification service extensions. - `apps/ios/ADE/Models/RemoteModels.swift` — Codable models used by - sync/mobile snapshots; `IntegrationProposal` mirrors desktop merge - target fields such as `preferredIntegrationLaneId` and + sync/mobile snapshots; carries `StartCliSessionResult` for + `work.startCliSession` and `IntegrationProposal` fields that mirror + desktop merge targets such as `preferredIntegrationLaneId` and `mergeIntoHeadSha`. - `apps/ios/ADE/Resources/DatabaseBootstrap.sql` — generated bootstrap schema copied from desktop `kvDb.ts`; includes @@ -352,7 +364,12 @@ Mobile-originated `command` envelopes are deduplicated through a short-lived `mobileCommandResultCache` (TTL 30 minutes, 512 entries) plus a persisted journal, so a phone that retries the same `commandId` after a reconnect receives the cached `command_ack` / -`command_result` instead of double-executing the action. +`command_result` instead of double-executing the action. Persisted +results are intentionally narrow: `work.runQuickCommand` and +`work.startCliSession` keep only the returned `sessionId` / `ptyId` +(and the `TerminalSessionSummary` for CLI launches), while failed +commands store a generic failure message instead of the original +payload. ### Sub-protocols at a glance diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index df29a14d6..76703ce62 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -44,8 +44,9 @@ apps/ios/ │ │ │ # push-token collection │ │ └── SyncService.swift # WebSocket client, command routing, │ │ # PIN pairing, lane presence, terminal -│ │ # input/resize, chat push, push-token -│ │ # registration, worktree discovery +│ │ # subscribe/unsubscribe + input/resize, +│ │ # CLI launcher (startCliSession), chat push, +│ │ # push-token registration, worktree discovery │ ├── Shared/ │ │ ├── ADESharedContainer.swift # App Group UserDefaults + WorkspaceSnapshot helpers │ │ ├── ADESharedModels.swift # AgentSnapshot, PrSnapshot — shared with widgets @@ -65,7 +66,10 @@ apps/ios/ │ │ ├── Files/ # FilesRootScreen, FilesDirectoryScreen, │ │ │ # FilesDetailScreen, *+Actions helpers │ │ ├── Work/ # WorkRootScreen, WorkChatSessionView, -│ │ │ # Work*Helpers, WorkNewChat*, +│ │ │ # Work*Helpers, WorkNewChatScreen (chat/CLI +│ │ │ # segmented launcher), WorkArtifactTerminalViews, +│ │ │ # WorkTerminalEmulatorView (UIKit-backed monospaced +│ │ │ # terminal screen + viewport reporter), │ │ │ # WorkSessionDestination*, │ │ │ # WorkRootScreen+Selection (multi-select state + │ │ │ # bulk close/archive/restore/delete/export), @@ -290,7 +294,7 @@ Implemented envelope types on iOS: | `command_ack` | Host → phone | Command receipt | | `command_result` | Host → phone | Execution result or error | | `file_request` / `file_response` | Bidirectional | On-demand file access | -| `terminal_subscribe` / `terminal_data` | Phone → host / host → phone | Terminal streaming | +| `terminal_subscribe` / `terminal_unsubscribe` / `terminal_data` | Phone ↔ host | Terminal streaming; `unsubscribe` is sent when a Work terminal screen disappears so the phone stops accumulating buffer for off-screen sessions | | `terminal_input` / `terminal_resize` | Phone → host | Raw input bytes and viewport size changes for a subscribed live PTY | | `chat_subscribe` / `chat_event` | Phone → host / host → phone | Agent chat transcript streaming | | `heartbeat` | Bidirectional | Connection health (30s) | @@ -594,7 +598,7 @@ so the iOS side can decode them with stock UIImage. |---|---|---|---| | **Lanes** | `square.stack.3d.up` | `/lanes` | Full lane surface: search/filter chips, open/create/attach/manage, multi-attach for unregistered worktrees, stack canvas, git/diff/rebase/conflicts, template-backed environment setup progress, lane-scoped sessions and AI chats. `devicesOpen` presence chips show which other devices currently have the lane open. The lane gear opens `LaneAdvancedScreen`, a single page that groups Manage / Switch branch / Stash and the destructive git escape hatches (rebase lane, rebase descendants, rebase + push, force push) with an inline description per row and an offline disabled banner. The commit sheet (`LaneCommitSheet`) renders staged + unstaged file lists with per-file stage / unstage / discard / restore / open-diff / open-files actions, a "Suggest" AI button gated by host capability, and a setup-hint card surfaced when the host returns "AI commit messages are off". | | **Files** | `doc.text` | `/files` | Lane-backed workspace picker, live file tree/search/read, protected-workspace read-only parity. `mobileReadOnly` on the workspace payload gates mutating file actions on the phone via `ensureMobileFileMutationsAllowed`; quick-open and text-search result lists cap visible rows at 40 and ask the user to refine when more matches exist. | -| **Work** | `terminal` | `/work` | Terminal + chat session list, cached history with persisted lane names, output streaming, character-by-character terminal input (Termius-style: each typed glyph forwards a single `terminal_input` byte and the field clears so PTY echo is the only source of truth), Ctrl-C forwarding for subscribed live PTYs, quick-launch actions, session pinning, live chat-event push from the host (no polling lag once subscribed). The terminal viewer renders ANSI SGR colors + bold and replays cursor/erase sequences via `WorkTerminalTextReplay` so agent CLI output stays readable on iPhone. The earlier "activity feed" section was retired — running chats are surfaced through the session list and the live-count chip. | +| **Work** | `terminal` | `/work` | Terminal + chat session list, cached history with persisted lane names, output streaming, character-by-character terminal input (Termius-style: each typed glyph forwards a single `terminal_input` byte and the field clears so PTY echo is the only source of truth), Ctrl-C forwarding for subscribed live PTYs, in-app CLI session launcher (Claude / Codex / Cursor / OpenCode / Droid / shell), tap-to-resume on ended PTY rows, session pinning, live chat-event push from the host (no polling lag once subscribed). The new-session screen (`WorkNewChatScreen`) toggles between **ADE chat** and **CLI session** via a segmented picker; the CLI mode submits `work.startCliSession` with the chosen provider, permission mode, and an optional opening message that the host types into the spawned PTY for non-shell providers. The terminal viewer (`WorkTerminalEmulatorView`) is a UIKit-backed monospaced screen that drives a `WorkTerminalScreen` model, computes its viewport in (cols, rows) from the rendered glyph cell, forwards each viewport change as `terminal_resize`, and unsubscribes via `terminal_unsubscribe` when the screen disappears. The earlier "activity feed" section was retired — running chats are surfaced through the session list and the live-count chip. | | **PRs** | `arrow.triangle.pull` | `/prs` | PR list/detail driven by `prs.getMobileSnapshot`: stack visibility (`PrStackSheet`), create-PR wizard (`CreatePrWizardView`) gated by per-lane eligibility, workflow cards (queue / integration / rebase) rendered from `PrWorkflowCard`, per-PR action capabilities. | | **CTO** | `sparkles` | `/cto` | CTO snapshot: Chat / Team / Workflows segments, with the mobile workflows screen mirroring the desktop workflow policy/dashboard and preserving the shared glass navigation chrome. Drills into per-worker chat sessions via `CtoSessionDestinationView`. | | **Settings** | `gearshape` | `/settings` (sync subset) | PIN pairing (`SettingsPinSheet`), notification preferences (`NotificationsCenterView`), quiet hours, per-session overrides, appearance, diagnostics, connection header with QR payload and address candidates, reconnect, forget. | @@ -717,7 +721,7 @@ reflected in the phone's UI on the next descriptor read. | Project home + desktop project switching | Implemented | | Lanes tab | Implemented to live desktop parity (with `devicesOpen`, multi-attach, stack canvas, and template environment progress) | | Files tab | Implemented with `mobileReadOnly` workspace gate and capped search/quick-open result rendering | -| Work tab | Implemented; live chat-event push from host plus subscribed terminal input/resize control | +| Work tab | Implemented; live chat-event push from host, subscribed terminal input/resize control with `terminal_unsubscribe` on view disappear, in-app CLI session launcher (`work.startCliSession`), tap-to-resume on ended PTY rows | | PRs tab | Implemented; driven by `prs.getMobileSnapshot` | | Settings tab (pairing / appearance / diagnostics) | Implemented | | Missions tab | Planned | @@ -816,6 +820,33 @@ reflected in the phone's UI on the next descriptor read. `itemId` or (b) the immediately preceding envelope was also assistant text. This keeps the iOS Work chat from fanning a single assistant turn into many tiny rows. +- **CLI launcher provider IDs are host-validated.** The Work + new-session screen sends `provider` strings that + `parseCliProvider` matches verbatim against + `claude | codex | cursor | droid | opencode | shell`. The phone + has no way to pass arbitrary `command` / `startupCommand` payloads + — those come from the shared + `apps/desktop/src/shared/cliLaunch.ts`. Adding a sixth provider + means updating both the host registry and the phone's + `workCliProviderOptions` together. +- **Tap-to-resume on a PTY row also goes through `work.startCliSession`.** + `WorkRootScreen+Actions` derives the provider from + `resumeMetadata.provider` (falling back to the row's `toolType` + prefix, with `shell` as the final fallback) and sends + `resumeSessionId: session.id`. The host rebuilds the resume command + line from the saved metadata or `resumeCommand` so the new PTY + attaches to the same agent thread the original session left behind. +- **`WorkTerminalEmulatorView` drives a monospaced grid, not a free + text view.** The viewport reported back to the host is in (cols, + rows) inferred from the rendered glyph cell, not pixel dimensions. + The emulator unsubscribes the host stream on `onDisappear` so a + user paging through the session list does not accumulate buffer + bytes for off-screen sessions; `restoreTerminalSubscriptions` + re-subscribes on reconnect for any session id still tracked in + `subscribedTerminalSessionIds`. Terminal snapshots request up to + 240 KB and local buffers trim at roughly 240,000 characters, keeping + recent CLI output available without letting an off-screen PTY grow + the mobile buffer indefinitely. - **Lane presence is best-effort with a TTL.** The phone re-announces on a 30 s cadence; the host prunes stale entries at 60 s. A phone that crashes without sending `lanes.presence.release` diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index d2fdb4334..b852a1387 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -8,7 +8,7 @@ host-side services, and replies with `command_ack` and then `command_result`. Source file: `apps/desktop/src/main/services/sync/syncRemoteCommandService.ts` -(~1,920 lines). +(~2,030 lines). ## Shape @@ -105,7 +105,7 @@ Listed in order of appearance in the registry: **Work** (`work.*`) - `listSessions`, `updateSessionMeta`, `runQuickCommand`, - `closeSession` + `startCliSession`, `closeSession` **Chat** (`chat.*`) - `listSessions`, `getSummary`, `getTranscript` @@ -185,6 +185,32 @@ A handful have more logic: - **`work.runQuickCommand`** — constructs a `PtyCreateArgs`, calls `ptyService.create`, and returns the PTY handle for the controller to subscribe to via `terminal_subscribe`. +- **`work.startCliSession`** — host-side mobile CLI launcher used by + the iOS Work "new session" surface and by tap-to-resume on existing + PTY rows. Args are validated through `parseStartCliSessionArgs`, + which restricts `provider` to the allowlist + `claude | codex | cursor | droid | opencode | shell` (any other + value throws `"work.startCliSession requires provider."`), clamps + `cols` to `[20, 240]` and `rows` to `[4, 120]`, and truncates + `initialInput` at 20 KB. Provider-specific argv, env, and shell + preambles come from `buildTrackedCliLaunchCommand` / + `buildTrackedCliResumeCommand` in `apps/desktop/src/shared/cliLaunch.ts` + — the same module the desktop Work tab uses — so the host owns the + startup-command shape and a phone cannot smuggle in a free-form + shell command (the `shell` provider takes no startup payload at all). + Claude launches mint a pre-assigned `--session-id` upfront via + `randomUUID()` so resume works as soon as the row exists. When + `resumeSessionId` is set, the handler reuses the saved + `resumeMetadata` (or the persisted `resumeCommand`) to rebuild the + startup line. After `ptyService.create` returns, any `initialInput` + is forwarded as keystrokes via `ptyService.writeBySessionId`. The + result is `SyncStartCliSessionResult` (`{ sessionId, ptyId, + session: TerminalSessionSummary | null }`) — the controller can + immediately render the session card and call `terminal_subscribe` + without an extra round-trip. The command-result journal persists + only the returned session handle and summary, not the `initialInput` + text, so reconnect replay does not leak the user's prompt into the + host-side ledger. - **`work.closeSession`** — looks up the session's PTY id and disposes the PTY. - **`chat.create`** — resolves a missing `model` to the first @@ -292,10 +318,10 @@ can be sensitive. controller observes the effect of a command through replicated `lanes`, `sessions`, `linear_workflow_runs`, etc. rows arriving after the host finishes the command. -- **Terminal sub-protocol** pairs with `work.runQuickCommand` + - `work.closeSession`. The controller invokes the command, then - sends `terminal_subscribe` with the returned PTY id to stream - output. +- **Terminal sub-protocol** pairs with `work.runQuickCommand`, + `work.startCliSession`, and `work.closeSession`. The controller + invokes the command, then sends `terminal_subscribe` with the + returned session id to stream output and enable input/resize control. - **Chat sub-protocol** pairs with `chat.create` / `chat.send` + `chat_subscribe`. Same pattern: create / send the message through a command, subscribe to the transcript stream for incremental @@ -345,6 +371,14 @@ see the chat README for the passive/active contract. `work.closeSession`. This is why headless ADE CLI mode provides a stub PTY service that throws on `.create` — the action is not supported there. +- **`work.startCliSession` provider list is host-controlled.** The + controller cannot pass `command` / `args` / `startupCommand` + overrides — the host derives those from the provider name through + `buildTrackedCliLaunchCommand`. To add a new provider you extend + `apps/desktop/src/shared/cliLaunch.ts` and the + `parseCliProvider` allowlist together; a phone client that hardcodes + the new id without a host update will get a "requires provider" + error. - **`files.writeTextAtomic` does not invoke git hooks or editors.** It writes atomically to the lane worktree and that is all. Services that care about post-write side effects (lint, diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index dc2f72994..8d9eb0f17 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -20,9 +20,10 @@ snapshot (the most recent run) is what lives in the `process_runtime` table. Main process: - `apps/desktop/src/main/services/pty/ptyService.ts` — PTY lifecycle, - transcript capture, runtime state, AI auto-titles, tool-type routing, - resume backfill, and session-id based write/resize entry points used - by mobile sync terminal control. ~1,500 lines. Branch rewrite. + transcript capture (capped at `MAX_TRANSCRIPT_BYTES = 64 MB`), runtime + state, AI auto-titles, tool-type routing, resume backfill, and + session-id based write/resize entry points used by mobile sync + terminal control. ~1,500 lines. Branch rewrite. - `apps/desktop/src/main/services/pty/ptyService.test.ts` — PTY behavior tests. Branch updated. - `apps/desktop/src/main/services/sessions/sessionService.ts` — persistence @@ -52,8 +53,12 @@ Shared types and IPC: `ChatTerminalSignalArgs`, `ChatTerminalActiveForChatArgs`) used by the `ade.terminal.*` IPC surface and the `terminal` ADE action domain. - `apps/desktop/src/shared/types/sync.ts` — terminal stream/control - envelopes (`terminal_subscribe`, `terminal_data`, `terminal_exit`, - `terminal_input`, `terminal_resize`) for iOS Work surfaces. + envelopes (`terminal_subscribe`, `terminal_unsubscribe`, + `terminal_data`, `terminal_exit`, `terminal_input`, `terminal_resize`) + for iOS Work surfaces, plus the mobile CLI launcher payload + (`SyncCliLaunchProvider`, `SyncStartCliSessionArgs`, + `SyncStartCliSessionResult`) consumed by the + `work.startCliSession` remote command. - `apps/desktop/src/shared/types/config.ts` — `ProcessDefinition` (now carries `groupIds: string[]`), `ProcessGroupDefinition`, `ProcessRuntime` (now carries `runId`), `ProcessRuntimeStatus`, @@ -155,10 +160,11 @@ Renderer surfaces: tab switch always renders the current session set. - `apps/desktop/src/renderer/components/terminals/useSessionDelta.ts` — fetches `SessionDeltaSummary` for a given session. -- `apps/desktop/src/renderer/components/terminals/cliLaunch.ts` — - builds tracked CLI launch payloads with permission flags for every - supported provider. `CliProvider = "claude" | "codex" | "cursor" | - "droid" | "opencode"` and `LaunchProfile = CliProvider | "shell"`; +- `apps/desktop/src/shared/cliLaunch.ts` — canonical CLI launch + payload builder, shared between the desktop renderer Work tab and + the main-process `syncRemoteCommandService` mobile launcher. Exposes + `CliProvider = "claude" | "codex" | "cursor" | "droid" | "opencode"` + and `LaunchProfile = CliProvider | "shell"`; `LAUNCH_PROFILE_TOOL_TYPE` and `LAUNCH_PROFILE_TITLE` map a launch profile to the recorded `TerminalToolType` (`cursor-cli`, `droid`, `opencode`, etc.) and the human tab title. `buildTrackedCliLaunchCommand` @@ -180,7 +186,21 @@ Renderer surfaces: rebuilds a resume command line from `TerminalResumeMetadata` for any provider; `parseTrackedCliResumeCommand` (`apps/desktop/src/main/utils/terminalSessionSignals.ts`) is the - inverse it relies on for round-tripping. + inverse it relies on for round-tripping. `resolveLaunchFields` is + the atomic-override helper that mixes a caller's + `command`/`args`/`startupCommand`/`env` with the profile defaults + (only when the caller passed nothing). +- `apps/desktop/src/renderer/components/terminals/cliLaunch.ts` — thin + re-export of `apps/desktop/src/shared/cliLaunch.ts` so existing + renderer callers keep their import path. +- `apps/desktop/src/shared/shell.ts` — shared shell-quoting and + command-line parsing utilities (`quoteShellArg`, `commandArrayToLine`, + `parseCommandLine`) used by both the renderer and the main-process + CLI launcher. Handles POSIX and Windows quoting rules behind a single + surface. +- `apps/desktop/src/renderer/lib/shell.ts` — thin re-export of + `apps/desktop/src/shared/shell.ts` to preserve existing renderer + imports. - `apps/desktop/src/shared/adeCliGuidance.ts` — single source of truth for the ADE session guidance text injected into Claude/Codex CLI launches. Exported as `ADE_CLI_AGENT_GUIDANCE` and @@ -213,15 +233,25 @@ Renderer surfaces: iOS Work surfaces: - `apps/ios/ADE/Views/Work/WorkRootScreen.swift` and - `WorkRootComponents.swift` — mobile Work list, filters, activity feed, - grouped session rows, and live-count/status pills. + `WorkRootScreen+Actions.swift` — mobile Work list, filters, + grouped session rows, live-count/status pills, and the resume + flow that re-uses `work.startCliSession` for ended PTY rows. - `apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift` — terminal artifact/output views and the compact input bar that sends - `terminal_input` bytes and Ctrl-C to the subscribed host PTY. + `terminal_input` bytes and Ctrl-C to the subscribed host PTY. Hosts + the new emulator surface and unsubscribes via + `SyncService.unsubscribeTerminal` on view disappear. +- `apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift` — + UIKit-backed monospaced terminal screen + `WorkTerminalScreen` + model that reports its viewport in (cols, rows) so the host can + resize the PTY to the phone's actual rendered grid. - `apps/ios/ADE/Views/Work/WorkChatSessionView.swift`, `WorkChatComposerAndInputViews.swift`, `WorkChatRichCardViews.swift`, `WorkReasoningCard.swift`, `WorkNewChatScreen.swift` — mobile chat, composer, command/tool/reasoning cards, and new-chat launch surface. + `WorkNewChatScreen` segments between **ADE chat** and **CLI session**; + the CLI mode submits `work.startCliSession` against the host through + `SyncService.startCliSession`. ## Detail docs @@ -278,7 +308,7 @@ See `apps/desktop/src/shared/types/sessions.ts` for the full shape. `terminal_sessions` row through `sessionService.create()`. 2. **Stream** — PTY `data` events are written to the transcript - (capped at `MAX_TRANSCRIPT_BYTES = 8 MB`), throttled into a + (capped at `MAX_TRANSCRIPT_BYTES = 64 MB`), throttled into a `lastOutputPreview`, forwarded to `broadcastData`, and scanned for runtime state signals (OSC 133 prompt markers). @@ -436,7 +466,7 @@ Processes (managed): falls back to `defaultResumeCommandForTool(toolType)`. Editing it directly is only allowed through `sessionService.setResumeCommand` or `updateMeta`, both of which re-derive the metadata. -- Transcript writes are capped at 8 MB; after the cap a notice line is +- Transcript writes are capped at 64 MB; after the cap a notice line is written once and further output is dropped. The runtime counter `transcriptBytesWritten` is not persisted. - Preview updates are throttled (~900 ms) and the string is capped at diff --git a/docs/features/terminals-and-sessions/pty-and-processes.md b/docs/features/terminals-and-sessions/pty-and-processes.md index edfbe9317..a730816c2 100644 --- a/docs/features/terminals-and-sessions/pty-and-processes.md +++ b/docs/features/terminals-and-sessions/pty-and-processes.md @@ -109,7 +109,7 @@ Each live PTY has an entry in the `ptys` map keyed by `ptyId` with: - `pty` (node-pty handle), `laneId`, `laneWorktreePath`, `boundCwd`, `sessionId`, `tracked` - transcript: `transcriptPath`, `transcriptStream`, - `transcriptBytesWritten`, `transcriptLimitReached` (8 MB cap from + `transcriptBytesWritten`, `transcriptLimitReached` (64 MB cap from `MAX_TRANSCRIPT_BYTES`) - preview: `lastPreviewWriteAt`, `previewCurrentLine`, `latestPreviewLine`, `lastPreviewWritten` @@ -168,7 +168,7 @@ Each live PTY has an entry in the `ptys` map keyed by `ptyId` with: ### Data, preview, and runtime state `writeTranscript(entry, data)` writes to the append-mode write stream. -Once the 8 MB cap is hit it writes a single notice line and drops +Once the 64 MB cap is hit it writes a single notice line and drops further output. Bytes written are not persisted, so the cap resets on reattach. diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md index 4e08ca9b9..3b6a84e81 100644 --- a/docs/features/terminals-and-sessions/ui-surfaces.md +++ b/docs/features/terminals-and-sessions/ui-surfaces.md @@ -409,12 +409,15 @@ Rendered when the Work view has no open sessions. Contains: the shell fallback the multi-line Cursor / Droid / OpenCode preambles always rely on. The recorded `toolType` and tab title come from the shared `LAUNCH_PROFILE_TOOL_TYPE` / `LAUNCH_PROFILE_TITLE` maps in - `cliLaunch.ts`, so adding a new provider only requires extending the - registry there plus the `WorkStartSurface` option list. + `apps/desktop/src/shared/cliLaunch.ts` (the renderer + `components/terminals/cliLaunch.ts` is now a thin re-export), so + adding a new provider only requires extending the shared registry + plus the `WorkStartSurface` option list — the same module also + powers the iOS `work.startCliSession` mobile launcher. - for shell drafts: a "Launch" button that opens an untracked shell PTY in the lane's worktree (`profile = "shell"`). -Launch commands are built by `cliLaunch.ts`: +Launch commands are built by `apps/desktop/src/shared/cliLaunch.ts`: - `buildTrackedCliLaunchCommand({ provider, permissionMode, ... })` returns the canonical `{ command?, args, startupCommand, env? }` @@ -490,7 +493,8 @@ argv-based spawn with ADE CLI guidance baked in. `profile` is a `LaunchProfile` (`"claude" | "codex" | "cursor" | "droid" | "opencode" | "shell"`); the matching tab title and recorded `TerminalToolType` come from the shared `LAUNCH_PROFILE_TITLE` / `LAUNCH_PROFILE_TOOL_TYPE` -maps in `cliLaunch.ts`. `inferToolFromResumeCommand` strips leading +maps in `apps/desktop/src/shared/cliLaunch.ts`. +`inferToolFromResumeCommand` strips leading `ENV=value` assignments before sniffing the provider, so resume commands the OpenCode preamble emits (`OPENCODE_CONFIG_CONTENT=… opencode --session …`) round-trip correctly. From 1a185b2580dc5f003af66da09650c1b9ae438d99 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 8 May 2026 00:25:19 -0400 Subject: [PATCH 2/8] ship: address review feedback Addresses review comments: 3206094825, 3206094865, 3206094975, 4403060699, 3206116137, 3206116148, 3206116150, 3206116152, 3206116157, 3206116159, 3206116162, 3206116164, 3206116167. --- apps/ade-cli/src/adeRpcServer.test.ts | 65 +++++++++++++++++ apps/ade-cli/src/adeRpcServer.ts | 59 +++++++++++----- apps/ade-cli/src/cli.test.ts | 33 +++++++++ apps/ade-cli/src/cli.ts | 66 +++++++++++++++--- .../src/main/services/pty/ptyService.test.ts | 18 +++++ .../src/main/services/pty/ptyService.ts | 3 +- .../sync/syncRemoteCommandService.test.ts | 69 +++++++++++++++++++ .../services/sync/syncRemoteCommandService.ts | 55 ++++++++++++--- .../components/terminals/cliLaunch.test.ts | 44 ++++++++---- apps/desktop/src/renderer/lib/shell.test.ts | 14 ++++ apps/desktop/src/shared/cliLaunch.ts | 52 ++++++++++++++ apps/desktop/src/shared/shell.ts | 22 ++++-- apps/ios/ADE/Views/Lanes/LaneTypes.swift | 16 ++--- .../ADE/Views/Work/WorkNewChatScreen.swift | 41 +++++++++-- .../Views/Work/WorkRootScreen+Actions.swift | 20 +++++- .../Views/Work/WorkTerminalEmulatorView.swift | 55 +++++++++++++-- 16 files changed, 554 insertions(+), 78 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 1886be6a5..faa839510 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -2024,6 +2024,71 @@ describe("adeRpcServer", () => { ); }); + it("rejects start_cli_session resume when the session id is missing", async () => { + const fixture = createRuntime(); + fixture.runtime.sessionService.get.mockReturnValue(null); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { role: "orchestrator" }); + const response = await callTool(handler, "start_cli_session", { + laneId: "lane-1", + provider: "codex", + resumeSessionId: "missing-session", + }); + + expect(response.isError).toBe(true); + expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain("missing-session"); + expect(fixture.runtime.ptyService.create).not.toHaveBeenCalled(); + }); + + it("rejects start_cli_session resume for a different provider", async () => { + const fixture = createRuntime(); + fixture.runtime.sessionService.get.mockReturnValue({ + id: "session-existing", + laneId: "lane-1", + ptyId: "pty-existing", + tracked: true, + toolType: "claude", + title: "Claude", + status: "exited", + resumeCommand: "claude --resume old", + resumeMetadata: { + provider: "claude", + targetKind: "session", + targetId: "old", + launch: { permissionMode: "default" }, + }, + }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { role: "orchestrator" }); + const response = await callTool(handler, "start_cli_session", { + laneId: "lane-1", + provider: "codex", + resumeSessionId: "session-existing", + }); + + expect(response.isError).toBe(true); + expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain("belongs to claude, not codex"); + expect(fixture.runtime.ptyService.create).not.toHaveBeenCalled(); + }); + + it("rejects unsupported start_cli_session permission/provider combinations", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { role: "orchestrator" }); + const response = await callTool(handler, "start_cli_session", { + laneId: "lane-1", + provider: "claude", + permissionMode: "config-toml", + }); + + expect(response.isError).toBe(true); + expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain("config-toml is only supported for Codex"); + expect(fixture.runtime.ptyService.create).not.toHaveBeenCalled(); + }); + it("starts spawn_agent without writing an attached ADE server config", async () => { const fixture = createRuntime(); fixture.runtime.workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-spawn-workspace-")); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 9f7772b4f..ace94f41a 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -44,8 +44,12 @@ import { resolveAdeLayout } from "../../desktop/src/shared/adeLayout"; import { buildTrackedCliLaunchCommand, buildTrackedCliResumeCommand, + isLaunchProfile, + isTrackedCliPermissionMode, LAUNCH_PROFILE_TITLE, LAUNCH_PROFILE_TOOL_TYPE, + launchProfileForTerminalSession, + validateLaunchProfilePermissionMode, type CliProvider, type LaunchProfile, } from "../../desktop/src/shared/cliLaunch"; @@ -2125,24 +2129,20 @@ function assertNonEmptyString(value: unknown, field: string): string { return text; } -const CLI_SESSION_PROVIDERS: readonly LaunchProfile[] = ["claude", "codex", "cursor", "droid", "opencode", "shell"]; -const CLI_SESSION_PERMISSION_MODES: readonly AgentChatPermissionMode[] = ["default", "plan", "edit", "full-auto", "config-toml"]; - function parseCliSessionProvider(value: unknown): LaunchProfile { const provider = asTrimmedString(value).toLowerCase(); - const match = CLI_SESSION_PROVIDERS.find((entry) => entry === provider); - if (!match) { + if (!isLaunchProfile(provider)) { throw new JsonRpcError( JsonRpcErrorCode.invalidParams, "provider must be one of claude, codex, cursor, droid, opencode, or shell", ); } - return match; + return provider; } function parseCliSessionPermissionMode(value: unknown): AgentChatPermissionMode { const mode = asTrimmedString(value); - return CLI_SESSION_PERMISSION_MODES.find((entry) => entry === mode) ?? "default"; + return isTrackedCliPermissionMode(mode) ? mode : "default"; } function clampInteger(value: unknown, fallback: number, min: number, max: number): number { @@ -2154,6 +2154,25 @@ function isCliProvider(provider: LaunchProfile): provider is CliProvider { return provider !== "shell"; } +function requireCliResumeSession( + runtime: AdeRuntime, + sessionId: string, + provider: LaunchProfile, +): TerminalSessionSummary { + const session = runtime.sessionService.get(sessionId) as TerminalSessionSummary | null; + if (!session) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, `resumeSessionId '${sessionId}' was not found.`); + } + const existingProvider = launchProfileForTerminalSession(session); + if (existingProvider && existingProvider !== provider) { + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + `resumeSessionId '${sessionId}' belongs to ${existingProvider}, not ${provider}.`, + ); + } + return session; +} + export function resolveComputerUseOwners(session: SessionState, toolArgs: Record): ComputerUseArtifactOwner[] { const owners: ComputerUseArtifactOwner[] = []; const add = ( @@ -4324,22 +4343,24 @@ async function runTool(args: { const laneId = assertNonEmptyString(toolArgs.laneId, "laneId"); const provider = parseCliSessionProvider(toolArgs.provider); const permissionMode = parseCliSessionPermissionMode(toolArgs.permissionMode); + try { + validateLaunchProfilePermissionMode(provider, permissionMode); + } catch (err) { + throw new JsonRpcError(JsonRpcErrorCode.invalidParams, err instanceof Error ? err.message : String(err)); + } const cols = clampInteger(toolArgs.cols, DEFAULT_PTY_COLS, 20, 240); const rows = clampInteger(toolArgs.rows, DEFAULT_PTY_ROWS, 4, 120); const title = asOptionalTrimmedString(toolArgs.title) ?? LAUNCH_PROFILE_TITLE[provider]; const resumeSessionId = asOptionalTrimmedString(toolArgs.resumeSessionId); const resumeTargetId = asOptionalTrimmedString(toolArgs.resumeTargetId); const initialInput = asOptionalTrimmedString(toolArgs.initialInput)?.slice(0, 20_000) ?? null; - const ptyService = runtime.ptyService as typeof runtime.ptyService & { - writeBySessionId?: (sessionId: string, data: string) => boolean; - enrichSessions?: (sessions: TerminalSessionSummary[]) => TerminalSessionSummary[]; - }; + const resumeSession = resumeSessionId ? requireCliResumeSession(runtime, resumeSessionId, provider) : null; + const ptyService = runtime.ptyService; const preassignedSessionId = provider === "claude" && !resumeSessionId ? randomUUID() : undefined; const launchFields: { startupCommand?: string; command?: string; args?: string[]; env?: Record } = (() => { if (!isCliProvider(provider)) return {}; if (resumeSessionId || resumeTargetId) { - const resumeSession = resumeSessionId ? runtime.sessionService.get(resumeSessionId) : null; const startupCommand = resumeSession?.resumeMetadata ? buildTrackedCliResumeCommand(resumeSession.resumeMetadata) : resumeSession?.resumeCommand?.trim() @@ -4370,16 +4391,18 @@ async function runTool(args: { let initialInputWritten = false; if (initialInput && isCliProvider(provider)) { - if (typeof ptyService.writeBySessionId !== "function") { - throw new JsonRpcError(JsonRpcErrorCode.internalError, "PTY service does not support session-scoped writes."); - } initialInputWritten = ptyService.writeBySessionId(created.sessionId, `${initialInput}\r`); + if (!initialInputWritten) { + ptyService.dispose({ ptyId: created.ptyId, sessionId: created.sessionId }); + throw new JsonRpcError( + JsonRpcErrorCode.internalError, + "Created terminal session could not receive the initial input.", + ); + } } const session = runtime.sessionService.get(created.sessionId) as TerminalSessionSummary | null; - const enrichedSession = session && typeof ptyService.enrichSessions === "function" - ? ptyService.enrichSessions([session])[0] ?? session - : session; + const enrichedSession = session ? ptyService.enrichSessions([session])[0] ?? session : session; return { provider, laneId, diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index c801649ab..6a68ceeeb 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -890,6 +890,39 @@ describe("ADE CLI", () => { }); }); + it("does not treat option values as start-cli providers", () => { + expect(() => buildCliPlan([ + "shell", + "start-cli", + "--lane", + "lane-1", + "--permission-mode", + "edit", + ])).toThrow("provider is required"); + }); + + it("finds a start-cli provider after value-taking options", () => { + const plan = buildCliPlan([ + "shell", + "start-cli", + "--lane", + "lane-1", + "--permission-mode", + "edit", + "codex", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + name: "start_cli_session", + arguments: { + laneId: "lane-1", + provider: "codex", + permissionMode: "edit", + }, + }); + }); + it("accepts --provider on shell start as the CLI-session launcher", () => { const plan = buildCliPlan([ "shell", diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 7bc6ee52c..acbf760c7 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -12,7 +12,13 @@ import { } from "./cursorCloud"; import { type JsonRpcHandler, type JsonRpcId, type JsonRpcRequest } from "./jsonrpc"; import { isAdeMcpNamedPipePath } from "../../desktop/src/shared/adeMcpIpc"; -import { LAUNCH_PROFILE_TITLE, type LaunchProfile } from "../../desktop/src/shared/cliLaunch"; +import { + isLaunchProfile, + isTrackedCliPermissionMode, + LAUNCH_PROFILE_TITLE, + validateLaunchProfilePermissionMode, + type LaunchProfile, +} from "../../desktop/src/shared/cliLaunch"; type JsonObject = Record; @@ -1236,6 +1242,34 @@ function firstPositional(args: string[]): string | null { return value ?? null; } +function firstStandalonePositional(args: string[]): string | null { + let previousTokenWasValueCarrier = false; + for (let index = 0; index < args.length; index += 1) { + const token = args[index]!; + if (token === "--") return null; + if (previousTokenWasValueCarrier) { + previousTokenWasValueCarrier = false; + continue; + } + if (token.startsWith("-")) { + const flagName = token.includes("=") ? token.slice(0, token.indexOf("=")) : token; + previousTokenWasValueCarrier = !token.includes("=") && VALUE_CARRIER_FLAGS.has(flagName); + continue; + } + const [value] = args.splice(index, 1); + return value ?? null; + } + return null; +} + +function takeArgsAfterTerminator(args: string[]): string[] | null { + const index = args.indexOf("--"); + if (index < 0) return null; + const rest = args.slice(index + 1); + args.splice(index); + return rest; +} + function peekFirstPositional(args: string[]): string | null { return args.find((arg) => arg !== "--" && !arg.startsWith("-")) ?? null; } @@ -2233,11 +2267,10 @@ function buildShellPlan(args: string[]): CliPlan { readValue(args, ["--chat-session", "--chat-session-id", "--session", "--session-id"]) ?? process.env.ADE_CHAT_SESSION_ID, ); - const startupCommandIndex = args.indexOf("--"); - const startupCommand = startupCommandIndex >= 0 - ? args.splice(startupCommandIndex + 1).map(shellEscapeToken).join(" ") + const startupCommandArgs = takeArgsAfterTerminator(args); + const startupCommand = startupCommandArgs + ? startupCommandArgs.map(shellEscapeToken).join(" ") : readValue(args, ["--command", "-c"]); - if (startupCommandIndex >= 0) args.splice(startupCommandIndex, 1); const input = collectGenericObjectArgs(args, { ...(laneId ? { laneId } : {}), ...(chatSessionId ? { chatSessionId } : {}), @@ -2259,17 +2292,28 @@ function buildShellPlan(args: string[]): CliPlan { function buildCliSessionStartPlan(args: string[], providerArg?: string): CliPlan { const laneId = requireValue(readLaneId(args), "laneId"); - const provider = requireValue(providerArg ?? readValue(args, ["--provider", "--profile"]) ?? firstPositional(args), "provider") as LaunchProfile; - const promptIndex = args.indexOf("--"); - const initialInput = promptIndex >= 0 - ? args.splice(promptIndex + 1).join(" ").trim() + const rawProvider = requireValue( + providerArg ?? readValue(args, ["--provider", "--profile"]) ?? firstStandalonePositional(args), + "provider", + ); + if (!isLaunchProfile(rawProvider)) { + throw new CliUsageError("provider must be one of claude, codex, cursor, droid, opencode, or shell."); + } + const provider: LaunchProfile = rawProvider; + const promptArgs = takeArgsAfterTerminator(args); + const initialInput = promptArgs + ? promptArgs.join(" ").trim() : readValue(args, ["--message", "--prompt", "--initial-input"]); - if (promptIndex >= 0) args.splice(promptIndex, 1); + const permissionMode = readValue(args, ["--permission-mode", "--permissions"]) ?? "default"; + if (!isTrackedCliPermissionMode(permissionMode)) { + throw new CliUsageError("permissionMode must be one of default, plan, edit, full-auto, or config-toml."); + } + validateLaunchProfilePermissionMode(provider, permissionMode); const input = collectGenericObjectArgs(args, { laneId, provider, - permissionMode: readValue(args, ["--permission-mode", "--permissions"]) ?? "default", + permissionMode, title: readValue(args, ["--title"]) ?? LAUNCH_PROFILE_TITLE[provider] ?? undefined, initialInput, cols: readIntOption(args, ["--cols"], 120), diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index a55700dc3..c281abb87 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -2175,6 +2175,24 @@ describe("ptyService", () => { expect(read.nextSince).toBe(4 + "456789".length); }); + it("readTerminal defaults to a bounded transcript tail", async () => { + const { service, sessionService } = createChatHarness(); + await service.create({ + laneId: "lane-1", + title: "Reader", + cols: 80, + rows: 24, + chatSessionId: "chat-7", + }); + + await service.readTerminal({ chatSessionId: "chat-7" }); + expect(sessionService.readTranscriptTail).toHaveBeenCalledWith( + expect.stringContaining("/tmp/transcripts/"), + 220_000, + { raw: true }, + ); + }); + it("writeTerminal routes data via the active chat terminal and the underlying PTY", async () => { const { service, mockPty } = createChatHarness(); await service.create({ diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index cfaca7b99..c01ab2e35 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -60,6 +60,7 @@ const CLI_USER_TITLE_FALLBACK_MAX_LEN = 72; const PTY_DATA_BATCH_INTERVAL_MS = 16; const PTY_DATA_BATCH_MAX_CHARS = 64 * 1024; const PTY_DATA_SUMMARY_INTERVAL_MS = 10_000; +const DEFAULT_TERMINAL_READ_MAX_BYTES = 220_000; function hasEnvValue(env: NodeJS.ProcessEnv, key: string): boolean { return typeof env[key] === "string" && env[key]!.trim().length > 0; @@ -2190,7 +2191,7 @@ export function createPtyService({ if (!session) throw new Error(`Terminal session '${terminalId}' was not found.`); const maxBytes = typeof args.maxBytes === "number" && Number.isFinite(args.maxBytes) ? Math.max(1, Math.min(MAX_TRANSCRIPT_BYTES, Math.floor(args.maxBytes))) - : MAX_TRANSCRIPT_BYTES; + : DEFAULT_TERMINAL_READ_MAX_BYTES; const full = await sessionService.readTranscriptTail(session.transcriptPath, maxBytes, { raw: true }); const since = typeof args.since === "number" && Number.isFinite(args.since) ? Math.max(0, Math.floor(args.since)) diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index edd754749..43424bca8 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -1677,6 +1677,19 @@ describe("createSyncRemoteCommandService", () => { })); }); + it("work.startCliSession uses desktop-sized defaults when dimensions are omitted", async () => { + await service.execute(makePayload("work.startCliSession", { + laneId: "lane-1", + provider: "codex", + })); + expect(ptyService.create).toHaveBeenCalledWith( + expect.objectContaining({ + cols: 120, + rows: 36, + }), + ); + }); + it("work.startCliSession opens a shell without accepting arbitrary startup commands", async () => { await service.execute(makePayload("work.startCliSession", { laneId: "lane-1", @@ -1706,6 +1719,15 @@ describe("createSyncRemoteCommandService", () => { }))).rejects.toThrow("work.startCliSession requires provider."); }); + it("work.startCliSession rejects unsupported permission/provider combinations", async () => { + await expect(service.execute(makePayload("work.startCliSession", { + laneId: "lane-1", + provider: "claude", + permissionMode: "config-toml", + }))).rejects.toThrow("config-toml is only supported for Codex"); + expect(ptyService.create).not.toHaveBeenCalled(); + }); + it("work.startCliSession pre-assigns a claude --session-id so resume is reliable", async () => { await service.execute(makePayload("work.startCliSession", { laneId: "lane-1", @@ -1750,6 +1772,53 @@ describe("createSyncRemoteCommandService", () => { expect(call?.command).toBeUndefined(); }); + it("work.startCliSession fails fast when resumeSessionId is missing", async () => { + sessionService.get.mockReturnValue(null); + + await expect(service.execute(makePayload("work.startCliSession", { + laneId: "lane-1", + provider: "codex", + resumeSessionId: "missing-session", + }))).rejects.toThrow("missing-session"); + expect(ptyService.create).not.toHaveBeenCalled(); + }); + + it("work.startCliSession fails fast when resumeSessionId belongs to a different provider", async () => { + sessionService.get.mockReturnValue({ + id: "pty-existing", + laneId: "lane-1", + ptyId: "pty-existing", + toolType: "claude", + title: "Claude", + status: "running", + resumeCommand: "claude --resume old", + resumeMetadata: { + provider: "claude", + targetKind: "session", + targetId: "old", + launch: { permissionMode: "default" }, + }, + }); + + await expect(service.execute(makePayload("work.startCliSession", { + laneId: "lane-1", + provider: "codex", + resumeSessionId: "pty-existing", + }))).rejects.toThrow("belongs to claude, not codex"); + expect(ptyService.create).not.toHaveBeenCalled(); + }); + + it("work.startCliSession disposes the created session when initial input cannot be written", async () => { + ptyService.writeBySessionId.mockReturnValueOnce(false); + + await expect(service.execute(makePayload("work.startCliSession", { + laneId: "lane-1", + provider: "codex", + initialInput: "fix the tests", + }))).rejects.toThrow("could not write initialInput"); + expect(ptyService.dispose).toHaveBeenCalledWith({ ptyId: "pty-proc", sessionId: "pty-1" }); + }); + it("work.closeSession disposes pty if session has a ptyId", async () => { sessionService.get.mockReturnValue({ ptyId: "pty-42" }); const result = await service.execute(makePayload("work.closeSession", { diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts index 0b2749e0b..004cc3854 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts @@ -98,6 +98,7 @@ import type { SyncStartCliSessionArgs, SyncStartCliSessionResult, SyncRunQuickCommandArgs, + TerminalSessionSummary, UpdateSessionMetaArgs, UpdateIntegrationProposalArgs, TerminalToolType, @@ -109,9 +110,13 @@ import type { import { buildTrackedCliLaunchCommand, buildTrackedCliResumeCommand, + isLaunchProfile, + isTrackedCliPermissionMode, LAUNCH_PROFILE_TITLE, LAUNCH_PROFILE_TOOL_TYPE, + launchProfileForTerminalSession, resolveTrackedCliResumeCommand, + validateLaunchProfilePermissionMode, } from "../../../shared/cliLaunch"; import { normalizePrCreationStrategy } from "../../../shared/prStrategy"; import type { createAgentChatService } from "../chat/agentChatService"; @@ -493,8 +498,8 @@ function parseQuickCommandArgs(value: Record): SyncRunQuickComm }; } -const CLI_LAUNCH_PROVIDERS = ["claude", "codex", "cursor", "droid", "opencode", "shell"] as const; -const CLI_PERMISSION_MODES = ["default", "plan", "edit", "full-auto", "config-toml"] as const; +const DEFAULT_CLI_COLS = 120; +const DEFAULT_CLI_ROWS = 36; function clampCliDimension(value: number | undefined, fallback: number, min: number, max: number): number { return Math.max(min, Math.min(max, Math.floor(value ?? fallback))); @@ -502,14 +507,13 @@ function clampCliDimension(value: number | undefined, fallback: number, min: num function parseCliProvider(value: unknown): SyncStartCliSessionArgs["provider"] { const provider = asTrimmedString(value)?.toLowerCase(); - const match = CLI_LAUNCH_PROVIDERS.find((p) => p === provider); - if (!match) throw new Error("work.startCliSession requires provider."); - return match; + if (!isLaunchProfile(provider)) throw new Error("work.startCliSession requires provider."); + return provider; } function parseCliPermissionMode(value: unknown): SyncStartCliSessionArgs["permissionMode"] { const mode = asTrimmedString(value); - return CLI_PERMISSION_MODES.find((m) => m === mode) ?? "default"; + return isTrackedCliPermissionMode(mode) ? mode : "default"; } function parseStartCliSessionArgs(value: Record): SyncStartCliSessionArgs { @@ -530,6 +534,20 @@ function parseStartCliSessionArgs(value: Record): SyncStartCliS }; } +function requireResumeSessionForProvider( + sessionService: ReturnType, + sessionId: string, + provider: SyncStartCliSessionArgs["provider"], +): TerminalSessionSummary { + const session = sessionService.get(sessionId) as TerminalSessionSummary | null; + if (!session) throw new Error(`work.startCliSession resumeSessionId '${sessionId}' was not found.`); + const existingProvider = launchProfileForTerminalSession(session); + if (existingProvider && existingProvider !== provider) { + throw new Error(`work.startCliSession resumeSessionId '${sessionId}' belongs to ${existingProvider}, not ${provider}.`); + } + return session; +} + function isChatToolType(toolType: string | null | undefined): boolean { if (!toolType) return false; const t = toolType.trim().toLowerCase(); @@ -1763,11 +1781,15 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg }); register("work.startCliSession", { viewerAllowed: true, queueable: true }, async (payload) => { const parsed = parseStartCliSessionArgs(payload); - const cols = clampCliDimension(parsed.cols, 88, 20, 240); - const rows = clampCliDimension(parsed.rows, 28, 4, 120); + const cols = clampCliDimension(parsed.cols, DEFAULT_CLI_COLS, 20, 240); + const rows = clampCliDimension(parsed.rows, DEFAULT_CLI_ROWS, 4, 120); const resumeSessionId = parsed.resumeSessionId?.trim() || undefined; const { provider } = parsed; const permissionMode = parsed.permissionMode ?? "default"; + validateLaunchProfilePermissionMode(provider, permissionMode); + const resumeSession = resumeSessionId + ? requireResumeSessionForProvider(args.sessionService, resumeSessionId, provider) + : null; const toolType = LAUNCH_PROFILE_TOOL_TYPE[provider] as TerminalToolType; const title = parsed.title?.trim() || LAUNCH_PROFILE_TITLE[provider]; const preassignedSessionId = provider === "claude" && !resumeSessionId ? randomUUID() : undefined; @@ -1775,8 +1797,8 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg function resolveLaunch(): { startupCommand?: string; command?: string; args?: string[]; env?: Record } { if (provider === "shell") return {}; if (resumeSessionId) { - const resumeSession = args.sessionService.get(resumeSessionId); - const startupCommand = (resumeSession ? resolveTrackedCliResumeCommand(resumeSession) : null) + if (!resumeSession) throw new Error(`work.startCliSession resumeSessionId '${resumeSessionId}' was not found.`); + const startupCommand = resolveTrackedCliResumeCommand(resumeSession) ?? buildTrackedCliResumeCommand({ provider, targetKind: "session", @@ -1802,7 +1824,18 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg }); if (parsed.initialInput && provider !== "shell") { - args.ptyService.writeBySessionId(result.sessionId, `${parsed.initialInput}\r`); + const written = args.ptyService.writeBySessionId(result.sessionId, `${parsed.initialInput}\r`); + if (!written) { + try { + args.ptyService.dispose({ ptyId: result.ptyId, sessionId: result.sessionId }); + } catch (err) { + args.logger.warn("sync_remote.start_cli_session_initial_input_cleanup_failed", { + sessionId: result.sessionId, + err: String(err), + }); + } + throw new Error("work.startCliSession created a terminal session but could not write initialInput."); + } } const session = args.sessionService.get(result.sessionId); diff --git a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts index 6115eb14f..27d9d162d 100644 --- a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts +++ b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts @@ -82,10 +82,10 @@ describe("buildTrackedCliStartupCommand", () => { expect(command).toContain("--permission-mode plan"); }); - it("adds --permission-mode plan for config-toml (falls through to else)", () => { - const command = buildTrackedCliStartupCommand({ provider: "claude", permissionMode: "config-toml" }); - expect(command).toContain("--append-system-prompt"); - expect(command).toContain("--permission-mode plan"); + it("rejects config-toml before building unsupported Claude commands", () => { + expect(() => buildTrackedCliStartupCommand({ provider: "claude", permissionMode: "config-toml" })).toThrow( + "config-toml is only supported for Codex", + ); }); it("uses Claude's system-prompt hook for ADE guidance", () => { @@ -176,21 +176,30 @@ describe("buildTrackedCliStartupCommand", () => { expect(launch.env?.OPENCODE_CONFIG_CONTENT).toBe("{\"permission\":\"allow\"}"); expect(launch.startupCommand).toContain("OPENCODE_CONFIG_CONTENT=\"{\\\"permission\\\":\\\"allow\\\"}\" opencode"); }); + + it("rejects config-toml for providers that do not support it", () => { + expect(() => buildTrackedCliLaunchCommand({ provider: "cursor", permissionMode: "config-toml" })).toThrow( + "config-toml is only supported for Codex", + ); + expect(() => buildTrackedCliLaunchCommand({ provider: "droid", permissionMode: "config-toml" })).toThrow( + "config-toml is only supported for Codex", + ); + expect(() => buildTrackedCliLaunchCommand({ provider: "opencode", permissionMode: "config-toml" })).toThrow( + "config-toml is only supported for Codex", + ); + }); }); - it("covers all AgentChatPermissionMode values for both providers", () => { + it("covers supported AgentChatPermissionMode values for each provider", () => { const modes = ["default", "plan", "edit", "full-auto", "config-toml"] as const satisfies readonly AgentChatPermissionMode[]; for (const mode of modes) { - const claude = buildTrackedCliStartupCommand({ provider: "claude", permissionMode: mode }); const codex = buildTrackedCliStartupCommand({ provider: "codex", permissionMode: mode }); - const cursor = buildTrackedCliStartupCommand({ provider: "cursor", permissionMode: mode }); - const droid = buildTrackedCliStartupCommand({ provider: "droid", permissionMode: mode }); - const opencode = buildTrackedCliStartupCommand({ provider: "opencode", permissionMode: mode }); - expect(claude.length).toBeGreaterThan(0); expect(codex.length).toBeGreaterThan(0); - expect(cursor.length).toBeGreaterThan(0); - expect(droid.length).toBeGreaterThan(0); - expect(opencode.length).toBeGreaterThan(0); + if (mode === "config-toml") continue; + expect(buildTrackedCliStartupCommand({ provider: "claude", permissionMode: mode }).length).toBeGreaterThan(0); + expect(buildTrackedCliStartupCommand({ provider: "cursor", permissionMode: mode }).length).toBeGreaterThan(0); + expect(buildTrackedCliStartupCommand({ provider: "droid", permissionMode: mode }).length).toBeGreaterThan(0); + expect(buildTrackedCliStartupCommand({ provider: "opencode", permissionMode: mode }).length).toBeGreaterThan(0); } }); }); @@ -256,6 +265,15 @@ describe("tracked CLI resume helpers", () => { })).toContain("droid --settings \"$ADE_DROID_SETTINGS\" --resume"); }); + it("rejects unsupported resume permission/provider combinations", () => { + expect(() => buildTrackedCliResumeCommand({ + provider: "claude", + targetKind: "session", + targetId: "claude-session-1", + launch: { permissionMode: "config-toml" }, + })).toThrow("config-toml is only supported for Codex"); + }); + it("prefers structured metadata over the legacy resume command string", () => { const session = { resumeCommand: "codex resume picker", diff --git a/apps/desktop/src/renderer/lib/shell.test.ts b/apps/desktop/src/renderer/lib/shell.test.ts index 01b67e397..a9240ccc0 100644 --- a/apps/desktop/src/renderer/lib/shell.test.ts +++ b/apps/desktop/src/renderer/lib/shell.test.ts @@ -11,6 +11,11 @@ describe("shell helpers", () => { expect(parseCommandLine('pnpm --filter "web app" dev')).toEqual(["pnpm", "--filter", "web app", "dev"]); }); + it("preserves empty quoted POSIX arguments", () => { + expect(parseCommandLine('node "" " " \'\' tail')).toEqual(["node", "", " ", "", "tail"]); + expect(parseCommandLine(commandArrayToLine(["node", "", "tail"]))).toEqual(["node", "", "tail"]); + }); + it("round-trips escaped quotes inside a quoted argument", () => { const argv = parseCommandLine('node -e "console.log(\\"hello world\\")"'); expect(argv).toEqual(["node", "-e", 'console.log("hello world")']); @@ -31,6 +36,15 @@ describe("shell helpers", () => { ]); }); + it("preserves empty quoted Windows arguments", () => { + expect(parseCommandLine('node.exe "" " " "" tail', { platform: "win32" })).toEqual(["node.exe", "", " ", "", "tail"]); + expect(parseCommandLine(commandArrayToLine(["node.exe", "", "tail"], { platform: "win32" }), { platform: "win32" })).toEqual([ + "node.exe", + "", + "tail", + ]); + }); + it("round-trips Windows cmd and PowerShell commands", () => { const cmd = ["cmd.exe", "/c", "npm run test"]; const powershell = ["powershell.exe", "-NoProfile", "-Command", 'Write-Output "ok"']; diff --git a/apps/desktop/src/shared/cliLaunch.ts b/apps/desktop/src/shared/cliLaunch.ts index 5f260836f..e57ddbce9 100644 --- a/apps/desktop/src/shared/cliLaunch.ts +++ b/apps/desktop/src/shared/cliLaunch.ts @@ -16,6 +16,9 @@ export type TrackedCliLaunchCommand = { env?: Record; }; +export const LAUNCH_PROFILES = ["claude", "codex", "cursor", "droid", "opencode", "shell"] as const satisfies readonly LaunchProfile[]; +export const TRACKED_CLI_PERMISSION_MODES = ["default", "plan", "edit", "full-auto", "config-toml"] as const satisfies readonly AgentChatPermissionMode[]; + /** Maps a `launchPtySession` profile to the `TerminalToolType` recorded on the session. */ export const LAUNCH_PROFILE_TOOL_TYPE: Record = { claude: "claude", @@ -36,6 +39,49 @@ export const LAUNCH_PROFILE_TITLE: Record = { shell: "Shell", }; +const LAUNCH_PROFILE_TOOL_TYPES: Record = { + claude: ["claude", "claude-orchestrated", "claude-chat"], + codex: ["codex", "codex-orchestrated", "codex-chat"], + cursor: ["cursor-cli", "cursor"], + droid: ["droid", "droid-chat"], + opencode: ["opencode", "opencode-orchestrated", "opencode-chat"], + shell: ["shell", "run-shell"], +}; + +export function isLaunchProfile(value: string | null | undefined): value is LaunchProfile { + return typeof value === "string" && (LAUNCH_PROFILES as readonly string[]).includes(value); +} + +export function isTrackedCliPermissionMode(value: string | null | undefined): value is AgentChatPermissionMode { + return typeof value === "string" && (TRACKED_CLI_PERMISSION_MODES as readonly string[]).includes(value); +} + +export function validateLaunchProfilePermissionMode( + profile: LaunchProfile, + permissionMode: AgentChatPermissionMode | null | undefined, +): void { + const mode = permissionMode ?? "default"; + if (profile === "shell" && mode !== "default") { + throw new Error(`permissionMode ${mode} is not supported for shell sessions.`); + } + if (mode === "config-toml" && profile !== "codex") { + throw new Error("permissionMode config-toml is only supported for Codex CLI sessions."); + } +} + +export function launchProfileForTerminalSession( + session: Pick, +): LaunchProfile | null { + const resumeProvider = session.resumeMetadata?.provider; + if (resumeProvider) return resumeProvider; + const toolType = session.toolType; + if (!toolType) return null; + for (const profile of LAUNCH_PROFILES) { + if (LAUNCH_PROFILE_TOOL_TYPES[profile].includes(toolType)) return profile; + } + return null; +} + export function withCodexNoAltScreen(command: string): string { const trimmed = command.trim(); if (!/^codex(?:\s|$)/.test(trimmed)) return trimmed; @@ -76,6 +122,8 @@ export function buildTrackedCliLaunchCommand(args: { /** Pre-assigned session ID for Claude CLI (enables reliable resume). */ sessionId?: string; }): TrackedCliLaunchCommand { + validateLaunchProfilePermissionMode(args.provider, args.permissionMode); + if (args.provider === "claude") { const commandArgs: string[] = []; // Inject --session-id so we know the Claude session ID upfront for resume. @@ -264,6 +312,8 @@ function buildOpenCodeCommandParts(args: { } export function buildTrackedCliResumeCommand(metadata: TerminalResumeMetadata): string { + validateLaunchProfilePermissionMode(metadata.provider, metadata.launch.permissionMode); + const targetId = metadata.targetId?.trim() ?? ""; if (metadata.provider === "claude") { const parts = ["claude", ...permissionModeToClaudeFlag(metadata.launch.permissionMode)]; @@ -328,6 +378,8 @@ export function resolveLaunchFields

(args: { args?: string[]; env?: Record; }): { startupCommand?: string; command?: string; args?: string[]; env?: Record } { + validateLaunchProfilePermissionMode(args.profile, args.permissionMode); + const callerHasOverride = args.startupCommand !== undefined || args.command !== undefined diff --git a/apps/desktop/src/shared/shell.ts b/apps/desktop/src/shared/shell.ts index cfe440d71..a1a61fde3 100644 --- a/apps/desktop/src/shared/shell.ts +++ b/apps/desktop/src/shared/shell.ts @@ -69,6 +69,7 @@ export function parseCommandLine(input: string, options: { platform?: ShellPlatf const out: string[] = []; let current = ""; + let currentStarted = false; let quote: "\"" | "'" | null = null; let escaped = false; @@ -77,6 +78,7 @@ export function parseCommandLine(input: string, options: { platform?: ShellPlatf if (escaped) { current += ch; + currentStarted = true; escaped = false; continue; } @@ -97,39 +99,46 @@ export function parseCommandLine(input: string, options: { platform?: ShellPlatf i += 1; current += next; } + currentStarted = true; } else { current += ch; + currentStarted = true; } continue; } if (ch === "\\") { escaped = true; + currentStarted = true; continue; } if (ch === "'" || ch === "\"") { quote = ch; + currentStarted = true; continue; } if (/\s/.test(ch)) { - if (current.length) { + if (currentStarted) { out.push(current); current = ""; + currentStarted = false; } continue; } current += ch; + currentStarted = true; } if (escaped) current += "\\"; if (quote != null) throw new Error("Unclosed quote in command line"); - if (current.length) out.push(current); + if (currentStarted) out.push(current); return out; } function parseWindowsCommandLine(input: string): string[] { const out: string[] = []; let current = ""; + let currentStarted = false; let inQuotes = false; for (let i = 0; i < input.length; i += 1) { @@ -141,6 +150,7 @@ function parseWindowsCommandLine(input: string): string[] { const count = end - i; if (input[end] === "\"") { current += "\\".repeat(Math.floor(count / 2)); + currentStarted = true; if (count % 2 === 0) { if (inQuotes && input[end + 1] === "\"") { current += "\""; @@ -155,6 +165,7 @@ function parseWindowsCommandLine(input: string): string[] { } } else { current += "\\".repeat(count); + currentStarted = true; i = end - 1; } continue; @@ -166,22 +177,25 @@ function parseWindowsCommandLine(input: string): string[] { i += 1; } else { inQuotes = !inQuotes; + currentStarted = true; } continue; } if (!inQuotes && /\s/.test(ch)) { - if (current.length) { + if (currentStarted) { out.push(current); current = ""; + currentStarted = false; } continue; } current += ch; + currentStarted = true; } if (inQuotes) throw new Error("Unclosed quote in command line"); - if (current.length) out.push(current); + if (currentStarted) out.push(current); return out; } diff --git a/apps/ios/ADE/Views/Lanes/LaneTypes.swift b/apps/ios/ADE/Views/Lanes/LaneTypes.swift index 4da4b6c7f..8d048175c 100644 --- a/apps/ios/ADE/Views/Lanes/LaneTypes.swift +++ b/apps/ios/ADE/Views/Lanes/LaneTypes.swift @@ -136,8 +136,8 @@ enum LaneFileConfirmation: Identifiable { switch self { case .discardUnstaged: return "Discard changes?" case .discardAllUnstaged: return "Discard all unstaged changes?" - case .restoreStaged: return "Restore staged file?" - case .restoreAllStaged: return "Restore all staged files?" + case .restoreStaged: return "Discard staged changes?" + case .restoreAllStaged: return "Discard all staged changes?" } } @@ -148,9 +148,9 @@ enum LaneFileConfirmation: Identifiable { case .discardAllUnstaged(let files): return "Unstaged changes to \(files.count) file\(files.count == 1 ? "" : "s") will be permanently lost." case .restoreStaged: - return "Staged and unstaged changes to this file will be permanently lost." + return "Staged changes to this file will be removed from the index and worktree. Unstaged edits to the same file will also be permanently lost." case .restoreAllStaged(let files): - return "Staged changes to \(files.count) file\(files.count == 1 ? "" : "s") will be permanently lost. Unstaged edits on those files will be discarded too." + return "Staged changes to \(files.count) file\(files.count == 1 ? "" : "s") will be removed from the index and worktree. Unstaged edits to the same file\(files.count == 1 ? "" : "s") will also be permanently lost." } } @@ -158,8 +158,8 @@ enum LaneFileConfirmation: Identifiable { switch self { case .discardUnstaged: return "Discard" case .discardAllUnstaged: return "Discard all" - case .restoreStaged: return "Restore" - case .restoreAllStaged: return "Restore all" + case .restoreStaged: return "Discard staged" + case .restoreAllStaged: return "Discard staged" } } @@ -167,8 +167,8 @@ enum LaneFileConfirmation: Identifiable { switch self { case .discardUnstaged: return "discard file" case .discardAllUnstaged: return "discard all" - case .restoreStaged: return "restore staged file" - case .restoreAllStaged: return "restore staged files" + case .restoreStaged: return "discard staged file" + case .restoreAllStaged: return "discard staged files" } } diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index 008aa4753..4f47ceafb 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -126,9 +126,8 @@ struct WorkNewChatScreen: View { } } .onChange(of: sessionMode) { _, newMode in - if newMode == .chat && !["claude", "codex", "cursor", "opencode"].contains(provider) { - provider = "claude" - modelId = "claude-sonnet-4-6" + if newMode == .chat { + normalizeChatSelection() } } .onChange(of: modelId) { _, newModel in @@ -311,7 +310,7 @@ struct WorkNewChatScreen: View { pinned: false, manuallyNamed: nil, goal: nil, - toolType: provider == "shell" ? "shell" : (provider == "cursor" ? "cursor-cli" : provider), + toolType: nil, title: workCliProviderOptions.first(where: { $0.id == provider })?.title ?? providerLabel(provider), status: "running", startedAt: workDateFormatter.string(from: Date()), @@ -354,6 +353,40 @@ struct WorkNewChatScreen: View { return false } } + + private func normalizeChatSelection() { + let normalizedProvider = workNormalizedNewChatProvider(provider) + if provider != normalizedProvider { + provider = normalizedProvider + } + if !workNewChatModel(modelId, belongsTo: normalizedProvider) { + modelId = workDefaultNewChatModelId(provider: normalizedProvider) + } + runtimeMode = workDefaultRuntimeMode(provider: normalizedProvider) + if !modelSupportsReasoning(modelId: modelId, provider: normalizedProvider) { + reasoningEffort = "" + } + } +} + +private func workNormalizedNewChatProvider(_ provider: String) -> String { + let family = providerFamilyKey(provider) + return ["claude", "codex", "cursor", "opencode"].contains(family) ? family : "claude" +} + +private func workNewChatModel(_ modelId: String, belongsTo provider: String) -> Bool { + let trimmed = modelId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + return workModelCatalogGroupKey(for: trimmed, currentProvider: provider) == provider +} + +private func workDefaultNewChatModelId(provider: String) -> String { + switch workNormalizedNewChatProvider(provider) { + case "codex": return "gpt-5.5" + case "cursor": return "auto" + case "opencode": return "opencode/anthropic/claude-sonnet-4-6" + default: return "claude-sonnet-4-6" + } } private struct WorkNewChatComposerBar: View { diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift index 50db7abe8..244278702 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift @@ -343,10 +343,24 @@ extension WorkRootScreen { title: session.title, resumeSessionId: session.id ) - if let resumed = result.session { - optimisticSessions[resumed.id] = resumed + let sessionToOpen: TerminalSessionSummary = { + if let resumed = result.session { + return resumed + } + var fallback = session + fallback.id = result.sessionId + fallback.ptyId = result.ptyId ?? session.ptyId + fallback.status = "running" + fallback.runtimeState = "running" + fallback.endedAt = nil + fallback.exitCode = nil + return fallback + }() + if sessionToOpen.id != session.id { + optimisticSessions[session.id] = nil } - openSession(session) + optimisticSessions[sessionToOpen.id] = sessionToOpen + openSession(sessionToOpen) await reload(refreshRemote: true) } catch { errorMessage = error.localizedDescription diff --git a/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift b/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift index af2399342..74be51094 100644 --- a/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift +++ b/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift @@ -18,7 +18,7 @@ struct WorkTerminalEmulatorView: UIViewRepresentable { func makeUIView(context: Context) -> ADETerminalTextView { let view = ADETerminalTextView() view.onViewportChange = { viewport in - context.coordinator.updateViewport(viewport, rawText: rawText, view: view) + context.coordinator.updateViewport(viewport, rawText: rawText, revision: revision, view: view) } return view } @@ -26,7 +26,7 @@ struct WorkTerminalEmulatorView: UIViewRepresentable { func updateUIView(_ view: ADETerminalTextView, context: Context) { view.onViewportChange = { viewport in context.coordinator.onViewportChange = onViewportChange - context.coordinator.updateViewport(viewport, rawText: rawText, view: view) + context.coordinator.updateViewport(viewport, rawText: rawText, revision: revision, view: view) } context.coordinator.onViewportChange = onViewportChange context.coordinator.render(rawText: rawText, revision: revision, in: view) @@ -43,13 +43,14 @@ struct WorkTerminalEmulatorView: UIViewRepresentable { self.onViewportChange = onViewportChange } - func updateViewport(_ viewport: WorkTerminalViewport, rawText: String, view: ADETerminalTextView) { + func updateViewport(_ viewport: WorkTerminalViewport, rawText: String, revision: Int, view: ADETerminalTextView) { guard viewport != lastViewport else { return } lastViewport = viewport screen.resize(cols: viewport.cols) screen.reset() screen.write(rawText) lastRawText = rawText + lastRevision = revision view.render(screen.attributedString(font: view.terminalFont)) onViewportChange(viewport) } @@ -375,8 +376,11 @@ private final class WorkTerminalScreen { } } - private func applySGR(_ params: [Int]) { - for param in params { + private func applySGR(_ rawParams: [Int]) { + let params = rawParams.isEmpty ? [0] : rawParams + var index = params.startIndex + while index < params.endIndex { + let param = params[index] switch param { case 0: foreground = nil @@ -389,11 +393,22 @@ private final class WorkTerminalScreen { foreground = ansiColor(index: param - 30, bright: false) case 90...97: foreground = ansiColor(index: param - 90, bright: true) + case 38: + if index + 2 < params.endIndex, params[index + 1] == 5 { + if let color = ansi256Color(params[index + 2]) { + foreground = color + } + index += 2 + } else if index + 4 < params.endIndex, params[index + 1] == 2 { + foreground = rgbColor(red: params[index + 2], green: params[index + 3], blue: params[index + 4]) + index += 4 + } case 39: foreground = nil default: break } + index += 1 } } @@ -409,6 +424,36 @@ private final class WorkTerminalScreen { let palette = bright ? brightColors : normal return palette[max(0, min(index, palette.count - 1))] } + + private func ansi256Color(_ code: Int) -> UIColor? { + switch code { + case 0...7: + return ansiColor(index: code, bright: false) + case 8...15: + return ansiColor(index: code - 8, bright: true) + case 16...231: + let value = code - 16 + let levels: [CGFloat] = [0, 95, 135, 175, 215, 255] + return UIColor( + red: levels[value / 36] / 255, + green: levels[(value / 6) % 6] / 255, + blue: levels[value % 6] / 255, + alpha: 1 + ) + case 232...255: + let level = CGFloat(8 + (code - 232) * 10) / 255 + return UIColor(white: level, alpha: 1) + default: + return nil + } + } + + private func rgbColor(red: Int, green: Int, blue: Int) -> UIColor { + func channel(_ value: Int) -> CGFloat { + CGFloat(max(0, min(255, value))) / 255 + } + return UIColor(red: channel(red), green: channel(green), blue: channel(blue), alpha: 1) + } } private func terminalColorsEqual(_ lhs: UIColor?, _ rhs: UIColor?) -> Bool { From d97c100f142eeebd003820cc075a5f6793b6e6f6 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 8 May 2026 00:43:43 -0400 Subject: [PATCH 3/8] ship: address follow-up review feedback Addresses review comments: 3206267285, 3206267286, 3206267288, 3206284545, 3206290738, 3206290740, 3206290745, 4403344136. --- apps/ade-cli/src/adeRpcServer.test.ts | 41 ++++++++++++- apps/ade-cli/src/adeRpcServer.ts | 12 +++- apps/ade-cli/src/cli.test.ts | 60 +++++++++++++++++++ apps/ade-cli/src/cli.ts | 7 ++- .../ios/ADE/Views/Work/WorkModelCatalog.swift | 10 ++++ .../WorkNavigationAndTranscriptHelpers.swift | 23 +++++++ .../ADE/Views/Work/WorkNewChatScreen.swift | 15 ++++- .../Views/Work/WorkTerminalEmulatorView.swift | 27 ++++++++- apps/ios/ADETests/ADETests.swift | 8 +++ 9 files changed, 193 insertions(+), 10 deletions(-) diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index faa839510..309ce6ffe 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -278,7 +278,11 @@ function createRuntime() { ptyService: { create: vi.fn(async () => ({ ptyId: "pty-1", sessionId: "session-1" })), dispose: vi.fn(), - writeBySessionId: vi.fn(() => true), + writeBySessionId: vi.fn((sessionId: string, data: string): boolean => { + void sessionId; + void data; + return true; + }), enrichSessions: vi.fn((sessions: unknown[]) => sessions), }, testService: { @@ -2024,6 +2028,23 @@ describe("adeRpcServer", () => { ); }); + it("sanitizes start_cli_session resume target ids before building commands", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { role: "orchestrator" }); + const response = await callTool(handler, "start_cli_session", { + laneId: "lane-1", + provider: "codex", + resumeTargetId: "thread-1\n--danger", + }); + + expect(response?.isError).toBeUndefined(); + const createCall = fixture.runtime.ptyService.create.mock.calls.at(-1)?.[0]; + expect(createCall.startupCommand).toContain("thread-1 --danger"); + expect(createCall.startupCommand).not.toContain("\n"); + }); + it("rejects start_cli_session resume when the session id is missing", async () => { const fixture = createRuntime(); fixture.runtime.sessionService.get.mockReturnValue(null); @@ -2073,6 +2094,24 @@ describe("adeRpcServer", () => { expect(fixture.runtime.ptyService.create).not.toHaveBeenCalled(); }); + it("rejects invalid start_cli_session permission modes", async () => { + const fixture = createRuntime(); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { role: "orchestrator" }); + const response = await callTool(handler, "start_cli_session", { + laneId: "lane-1", + provider: "codex", + permissionMode: "surprise-me", + }); + + expect(response.isError).toBe(true); + expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain( + "permissionMode must be one of default, plan, edit, full-auto, or config-toml", + ); + expect(fixture.runtime.ptyService.create).not.toHaveBeenCalled(); + }); + it("rejects unsupported start_cli_session permission/provider combinations", async () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index ace94f41a..dc0c451cd 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -2141,8 +2141,13 @@ function parseCliSessionProvider(value: unknown): LaunchProfile { } function parseCliSessionPermissionMode(value: unknown): AgentChatPermissionMode { - const mode = asTrimmedString(value); - return isTrackedCliPermissionMode(mode) ? mode : "default"; + const mode = asTrimmedString(value).toLowerCase(); + if (!mode) return "default"; + if (isTrackedCliPermissionMode(mode)) return mode; + throw new JsonRpcError( + JsonRpcErrorCode.invalidParams, + "permissionMode must be one of default, plan, edit, full-auto, or config-toml", + ); } function clampInteger(value: unknown, fallback: number, min: number, max: number): number { @@ -4352,7 +4357,8 @@ async function runTool(args: { const rows = clampInteger(toolArgs.rows, DEFAULT_PTY_ROWS, 4, 120); const title = asOptionalTrimmedString(toolArgs.title) ?? LAUNCH_PROFILE_TITLE[provider]; const resumeSessionId = asOptionalTrimmedString(toolArgs.resumeSessionId); - const resumeTargetId = asOptionalTrimmedString(toolArgs.resumeTargetId); + const resumeTargetIdRaw = asOptionalTrimmedString(toolArgs.resumeTargetId); + const resumeTargetId = resumeTargetIdRaw ? stripInjectionChars(resumeTargetIdRaw) : null; const initialInput = asOptionalTrimmedString(toolArgs.initialInput)?.slice(0, 20_000) ?? null; const resumeSession = resumeSessionId ? requireCliResumeSession(runtime, resumeSessionId, provider) : null; const ptyService = runtime.ptyService; diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 6a68ceeeb..fa5a8887b 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -923,6 +923,38 @@ describe("ADE CLI", () => { }); }); + it("finds a start-cli provider after resume value-taking options", () => { + const cases: Array<[string, string, string]> = [ + ["--resume-session", "session-1", "resumeSessionId"], + ["--resume-session-id", "session-2", "resumeSessionId"], + ["--resume-target", "target-1", "resumeTargetId"], + ["--resume-target-id", "target-2", "resumeTargetId"], + ["--initial-input", "hello agent", "initialInput"], + ]; + + for (const [flag, value, field] of cases) { + const plan = buildCliPlan([ + "shell", + "start-cli", + "--lane", + "lane-1", + flag, + value, + "codex", + ]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + name: "start_cli_session", + arguments: { + laneId: "lane-1", + provider: "codex", + [field]: value, + }, + }); + } + }); + it("accepts --provider on shell start as the CLI-session launcher", () => { const plan = buildCliPlan([ "shell", @@ -1561,6 +1593,34 @@ describe("ADE CLI", () => { }); }); + it("keeps shell --command when an argument terminator has no trailing tokens", () => { + const plan = buildCliPlan(["shell", "start", "--lane", "lane-1", "--command", "npm test", "--"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { + domain: "pty", + action: "create", + args: { + startupCommand: "npm test", + }, + }, + }); + }); + + it("keeps start-cli --message when an argument terminator has no trailing tokens", () => { + const plan = buildCliPlan(["shell", "start-cli", "codex", "--lane", "lane-1", "--message", "hello", "--"]); + expect(plan.kind).toBe("execute"); + if (plan.kind !== "execute") return; + expect(plan.steps[0]?.params).toMatchObject({ + name: "start_cli_session", + arguments: { + provider: "codex", + initialInput: "hello", + }, + }); + }); + it("ios-sim type accepts clear text payload aliases without shadowing output --text", () => { const withValue = buildCliPlan(["ios-sim", "type", "--value", "hello", "--text"]); expect(withValue.kind).toBe("execute"); diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index acbf760c7..acb6fce61 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1267,7 +1267,7 @@ function takeArgsAfterTerminator(args: string[]): string[] | null { if (index < 0) return null; const rest = args.slice(index + 1); args.splice(index); - return rest; + return rest.length > 0 ? rest : null; } function peekFirstPositional(args: string[]): string | null { @@ -3359,7 +3359,7 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--description", "--domain", "--droid-autonomy", "--droid-permission-mode", "--duration-sec", "--enabled", "--event", "--end-x", "--end-y", "--file", "--fps", "--from", "--from-file", "--group", "--group-id", "--head", "--icon", "--id", - "--index", "--input", "--input-json", "--input-text", "--instructions", + "--index", "--initial-input", "--input", "--input-json", "--input-text", "--instructions", "--kind", "--json-input", "--lane", "--lane-id", "--limit", "--max-bytes", "--line", @@ -3371,7 +3371,8 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--path", "--permission-mode", "--permissions", "--port", "--pr", "--pr-id", "--pr-number", "--pr-url", "--process", "--process-id", "--project-root", "--prompt", "--provider", "--pty", "--pty-id", "--query", "--question", - "--reason", "--reasoning", "--recent-limit", "--ref", "--role", "--root", + "--reason", "--reasoning", "--recent-limit", "--ref", "--resume-session", "--resume-session-id", + "--resume-target", "--resume-target-id", "--role", "--root", "--root-lane", "--round", "--rounds", "--rows", "--rule", "--run", "--run-id", "--scalar", "--scalar-json", "--scope", "--seconds", "--session", "--session-id", "--set", "--set-json", "--sha", "--signal", "--since", "--source", "--source-lane", "--stack", "--stack-id", diff --git a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift index 9aa434baa..7bbc904b1 100644 --- a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift +++ b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift @@ -95,6 +95,16 @@ func workModelCatalog(currentModelId: String, currentProvider: String) -> [WorkM } } +func workDefaultCatalogModelId(provider: String) -> String? { + let family = providerFamilyKey(provider) + return workCuratedModelCatalogGroups() + .first(where: { $0.key == family })? + .providers + .flatMap(\.models) + .first? + .id +} + /// Desktop-shaped hierarchical catalog: group → provider → models. Mirrors /// `apps/desktop/src/shared/modelRegistry.ts` + `ModelCatalogPanel` so mobile /// users see the same CLAUDE / CODEX / CURSOR / OPENCODE tab strip and, within diff --git a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift index 41d87346f..0bf2b7b7e 100644 --- a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift @@ -199,10 +199,33 @@ private final class WorkTerminalTextReplay { column = max(0, max(1, params.dropFirst().first ?? 1) - 1) ensureCursor() case "J": + ensureCursor() if first == 2 || first == 3 { lines = [[]] row = 0 column = 0 + } else if first == 1 { + let space = WorkTerminalCell(scalar: " ") + for lineIndex in 0...row { + guard lines.indices.contains(lineIndex) else { continue } + if lineIndex == row { + let endIndex = min(column + 1, lines[lineIndex].count) + for cellIndex in 0.. private func workDefaultNewChatModelId(provider: String) -> String { switch workNormalizedNewChatProvider(provider) { - case "codex": return "gpt-5.5" + case "codex": return workDefaultCatalogModelId(provider: "codex") ?? "gpt-5.5" case "cursor": return "auto" case "opencode": return "opencode/anthropic/claude-sonnet-4-6" default: return "claude-sonnet-4-6" } } +private func workCliToolType(provider: String) -> String { + switch providerFamilyKey(provider) { + case "claude": return "claude" + case "codex": return "codex" + case "cursor": return "cursor-cli" + case "opencode": return "opencode" + case "droid": return "droid" + default: return "shell" + } +} + private struct WorkNewChatComposerBar: View { let sessionMode: WorkNewSessionMode @Binding var provider: String diff --git a/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift b/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift index 74be51094..40b49999b 100644 --- a/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift +++ b/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift @@ -356,7 +356,32 @@ private final class WorkTerminalScreen { column = max(0, min(cols - 1, max(1, params.dropFirst().first ?? 1) - 1)) ensureCursor() case "J": - if first == 2 || first == 3 { reset() } + ensureCursor() + if first == 2 || first == 3 { + reset() + } else if first == 1 { + let space = WorkTerminalCell(scalar: " ", foreground: foreground, bold: bold) + for lineIndex in 0...row { + guard lines.indices.contains(lineIndex) else { continue } + if lineIndex == row { + let endIndex = min(column + 1, lines[lineIndex].count) + for cellIndex in 0.. Date: Fri, 8 May 2026 01:01:38 -0400 Subject: [PATCH 4/8] ship: guard CLI cleanup error Addresses review comment: 3206348939. --- apps/ade-cli/src/adeRpcServer.test.ts | 22 ++++++++++++++++++++++ apps/ade-cli/src/adeRpcServer.ts | 6 +++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index 309ce6ffe..1e7cc194e 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1972,6 +1972,28 @@ describe("adeRpcServer", () => { }); }); + it("preserves the initial input write error if cleanup fails", async () => { + const fixture = createRuntime(); + fixture.runtime.ptyService.writeBySessionId.mockReturnValueOnce(false); + fixture.runtime.ptyService.dispose.mockImplementationOnce(() => { + throw new Error("already disposed"); + }); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { role: "orchestrator" }); + const response = await callTool(handler, "start_cli_session", { + laneId: "lane-1", + provider: "codex", + initialInput: "fix failing tests", + }); + + expect(response.isError).toBe(true); + expect(fixture.runtime.ptyService.dispose).toHaveBeenCalledWith({ ptyId: "pty-1", sessionId: "session-1" }); + expect(JSON.stringify(response.error ?? response.structuredContent ?? {})).toContain( + "Created terminal session could not receive the initial input.", + ); + }); + it("preassigns Claude session ids for start_cli_session launches", async () => { const fixture = createRuntime(); const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index dc0c451cd..dab113340 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -4399,7 +4399,11 @@ async function runTool(args: { if (initialInput && isCliProvider(provider)) { initialInputWritten = ptyService.writeBySessionId(created.sessionId, `${initialInput}\r`); if (!initialInputWritten) { - ptyService.dispose({ ptyId: created.ptyId, sessionId: created.sessionId }); + try { + ptyService.dispose({ ptyId: created.ptyId, sessionId: created.sessionId }); + } catch { + // Best-effort cleanup; preserve the caller-facing write failure. + } throw new JsonRpcError( JsonRpcErrorCode.internalError, "Created terminal session could not receive the initial input.", From eb5525ac3c08d528e729d20506c05cf6ae1030eb Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 8 May 2026 01:32:15 -0400 Subject: [PATCH 5/8] ship: address iOS terminal follow-up Addresses review comments: 3206463962, 3206464059. --- apps/ios/ADE/Services/SyncService.swift | 7 ++++++- apps/ios/ADE/Views/Work/WorkNewChatScreen.swift | 10 +++++++++- apps/ios/ADETests/ADETests.swift | 15 +++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index f6470ceef..1207ad92e 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -2343,7 +2343,7 @@ final class SyncService: ObservableObject { saveProfile(nil) saveRemoteCommandDescriptors([]) resetChatEventState(clearHistory: true) - resetTerminalSubscriptionState(clearHistory: true) + resetTerminalSubscriptionState(clearHistory: false) activeHostProfile = nil hostName = nil } @@ -5393,6 +5393,11 @@ final class SyncService: ObservableObject { advanceOutboundCursorForActiveProject(to: dbVersion) } + func seedTerminalBufferForTesting(sessionId: String, transcript: String) { + terminalBuffers[sessionId] = transcript + terminalBufferRevision += 1 + } + func pendingOperationsForTesting() -> [(id: String, kind: String, action: String, projectId: String?, projectRootPath: String?)] { loadPendingOperations().map { operation in ( diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index 826c8cbbd..cfb6502b8 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -291,7 +291,7 @@ struct WorkNewChatScreen: View { let result = try await syncService.startCliSession( laneId: selectedLaneId, provider: provider, - permissionMode: wire.permissionMode ?? (runtimeMode.isEmpty ? nil : runtimeMode), + permissionMode: workCliPermissionMode(provider: provider, runtimeMode: runtimeMode), title: workCliProviderOptions.first(where: { $0.id == provider })?.title, initialInput: opener.isEmpty ? nil : opener, cols: 88, @@ -389,6 +389,14 @@ private func workDefaultNewChatModelId(provider: String) -> String { } } +func workCliPermissionMode(provider: String, runtimeMode: String) -> String? { + if provider.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "shell" { + return nil + } + let wire = workRuntimeWireFields(provider: provider, mode: runtimeMode) + return wire.permissionMode ?? (runtimeMode.isEmpty ? nil : runtimeMode) +} + private func workCliToolType(provider: String) -> String { switch providerFamilyKey(provider) { case "claude": return "claude" diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index afd1f6aec..8c8de904d 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -45,6 +45,11 @@ final class ADETests: XCTestCase { XCTAssertEqual(output, "errok") } + func testShellCliPermissionModeDoesNotInheritRuntimeMode() { + XCTAssertNil(workCliPermissionMode(provider: "shell", runtimeMode: "plan")) + XCTAssertEqual(workCliPermissionMode(provider: "codex", runtimeMode: "plan"), "plan") + } + func testTerminalDisplayPreservesAnsiRunsForRendering() { let display = workTerminalDisplay( raw: "\u{001B}[31mError\u{001B}[0m plain \u{001B}[32;1mOK\u{001B}[0m", @@ -901,6 +906,16 @@ final class ADETests: XCTestCase { XCTAssertEqual(service.localStateRevision, unsubscribedRevision) } + @MainActor + func testTerminalBufferSurvivesCredentialClearingDisconnect() { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + + service.seedTerminalBufferForTesting(sessionId: "terminal-1", transcript: "full terminal history") + service.disconnect(clearCredentials: true) + + XCTAssertEqual(service.terminalBuffers["terminal-1"], "full terminal history") + } + @MainActor func testChatEventHistoryStoresDecodedEnvelopes() async throws { let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) From b8a9e2c027c6885364250675d6302ce9e3c52313 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 8 May 2026 02:40:37 -0400 Subject: [PATCH 6/8] ship: stabilize terminal subscriptions --- apps/ios/ADE/Services/SyncService.swift | 3 +- .../Work/WorkArtifactTerminalViews.swift | 32 ++++++++++++++++--- apps/ios/ADETests/ADETests.swift | 1 + 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 1207ad92e..631598560 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -5394,6 +5394,7 @@ final class SyncService: ObservableObject { } func seedTerminalBufferForTesting(sessionId: String, transcript: String) { + subscribedTerminalSessionIds.insert(sessionId) terminalBuffers[sessionId] = transcript terminalBufferRevision += 1 } @@ -6557,8 +6558,8 @@ final class SyncService: ObservableObject { } private func resetTerminalSubscriptionState(clearHistory: Bool) { - subscribedTerminalSessionIds.removeAll() if clearHistory { + subscribedTerminalSessionIds.removeAll() terminalBuffers.removeAll() } terminalBufferRevision += 1 diff --git a/apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift b/apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift index a8aa8e06d..4b4be6ecf 100644 --- a/apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift +++ b/apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift @@ -141,6 +141,7 @@ struct WorkTerminalSessionView: View { @State private var sendingFeedback = 0 @State private var lastSentTerminalSize: WorkTerminalViewport? @State private var currentTerminalViewport: WorkTerminalViewport? + @StateObject private var subscriptionLifecycle = WorkTerminalSubscriptionLifecycle() private var rawBuffer: String { syncService.terminalBuffers[session.id] ?? session.lastOutputPreview ?? "" @@ -170,13 +171,12 @@ struct WorkTerminalSessionView: View { .adeScreenBackground() .adeNavigationGlass() .sensoryFeedback(.impact(weight: .light), trigger: sendingFeedback) - .task { + .task(id: session.id) { + subscriptionLifecycle.markVisible() try? await syncService.subscribeTerminal(sessionId: session.id) } .onDisappear { - Task { - try? await syncService.unsubscribeTerminal(sessionId: session.id) - } + subscriptionLifecycle.scheduleUnsubscribe(sessionId: session.id, syncService: syncService) } .onChange(of: syncService.connectionState) { _, _ in lastSentTerminalSize = nil @@ -346,6 +346,30 @@ struct WorkTerminalSessionView: View { } } +@MainActor +private final class WorkTerminalSubscriptionLifecycle: ObservableObject { + private var generation = 0 + private var unsubscribeTask: Task? + + func markVisible() { + generation += 1 + unsubscribeTask?.cancel() + unsubscribeTask = nil + } + + func scheduleUnsubscribe(sessionId: String, syncService: SyncService) { + let generationAtDisappear = generation + unsubscribeTask?.cancel() + unsubscribeTask = Task { + await Task.yield() + guard !Task.isCancelled else { return } + let shouldUnsubscribe = await MainActor.run { generation == generationAtDisappear } + guard shouldUnsubscribe else { return } + try? await syncService.unsubscribeTerminal(sessionId: sessionId) + } + } +} + struct WorkFullscreenImageView: View { @Environment(\.dismiss) var dismiss let image: WorkFullscreenImage diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 8c8de904d..567a2b786 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -914,6 +914,7 @@ final class ADETests: XCTestCase { service.disconnect(clearCredentials: true) XCTAssertEqual(service.terminalBuffers["terminal-1"], "full terminal history") + XCTAssertEqual(service.subscribedTerminalSessionIds, Set(["terminal-1"])) } @MainActor From 6c079dd3e6ab98bc96172032ee97ee54aec7c273 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 8 May 2026 03:05:18 -0400 Subject: [PATCH 7/8] ship: address terminal review nits --- apps/ade-cli/README.md | 2 +- apps/ade-cli/src/cli.test.ts | 1 + .../WorkNavigationAndTranscriptHelpers.swift | 2 ++ .../Views/Work/WorkTerminalEmulatorView.swift | 31 ++++++++++++------- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 08ec3be19..a5f5569f6 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -68,7 +68,7 @@ ade run defs --text ade run start web --lane lane-id ade shell start --lane lane-id -- npm test ade shell start-cli codex --lane lane-id --permission-mode edit --message "fix failing tests" -ade shell start --provider claude --lane lane-id --permission-mode default +ade shell start-cli --provider claude --lane lane-id --permission-mode default ade chat create --lane lane-id --model gpt-5.5 ade tests run --lane lane-id --suite unit --wait ade proof list --arg ownerKind=chat --arg ownerId=session-id diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index fd42cd345..8c340f74d 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -1316,6 +1316,7 @@ describe("ADE CLI", () => { arguments: { laneId: "lane-1", provider: "claude", + permissionMode: "default", resumeSessionId: "session-1", }, }); diff --git a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift index 0bf2b7b7e..00ee4e136 100644 --- a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift @@ -204,6 +204,8 @@ private final class WorkTerminalTextReplay { lines = [[]] row = 0 column = 0 + foreground = nil + bold = false } else if first == 1 { let space = WorkTerminalCell(scalar: " ") for lineIndex in 0...row { diff --git a/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift b/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift index 40b49999b..bc8906493 100644 --- a/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift +++ b/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift @@ -17,16 +17,18 @@ struct WorkTerminalEmulatorView: UIViewRepresentable { func makeUIView(context: Context) -> ADETerminalTextView { let view = ADETerminalTextView() - view.onViewportChange = { viewport in - context.coordinator.updateViewport(viewport, rawText: rawText, revision: revision, view: view) + view.onViewportChange = { [weak view, weak coordinator = context.coordinator] viewport in + guard let view, let coordinator else { return } + coordinator.updateViewport(viewport, rawText: rawText, revision: revision, view: view) } return view } func updateUIView(_ view: ADETerminalTextView, context: Context) { - view.onViewportChange = { viewport in - context.coordinator.onViewportChange = onViewportChange - context.coordinator.updateViewport(viewport, rawText: rawText, revision: revision, view: view) + view.onViewportChange = { [weak view, weak coordinator = context.coordinator] viewport in + guard let view, let coordinator else { return } + coordinator.onViewportChange = onViewportChange + coordinator.updateViewport(viewport, rawText: rawText, revision: revision, view: view) } context.coordinator.onViewportChange = onViewportChange context.coordinator.render(rawText: rawText, revision: revision, in: view) @@ -46,11 +48,13 @@ struct WorkTerminalEmulatorView: UIViewRepresentable { func updateViewport(_ viewport: WorkTerminalViewport, rawText: String, revision: Int, view: ADETerminalTextView) { guard viewport != lastViewport else { return } lastViewport = viewport - screen.resize(cols: viewport.cols) - screen.reset() - screen.write(rawText) - lastRawText = rawText - lastRevision = revision + let columnsChanged = screen.resize(cols: viewport.cols) + if columnsChanged || revision != lastRevision || rawText != lastRawText { + screen.reset() + screen.write(rawText) + lastRawText = rawText + lastRevision = revision + } view.render(screen.attributedString(font: view.terminalFont)) onViewportChange(viewport) } @@ -158,9 +162,12 @@ private final class WorkTerminalScreen { private var bold = false private let maxLines = 4_000 - func resize(cols: Int) { - self.cols = max(20, min(240, cols)) + func resize(cols: Int) -> Bool { + let nextCols = max(20, min(240, cols)) + guard nextCols != self.cols else { return false } + self.cols = nextCols column = min(column, self.cols - 1) + return true } func reset() { From 8cecc90930df7e6763ef380f3fadde9c78dbd522 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 8 May 2026 03:20:30 -0400 Subject: [PATCH 8/8] ship: handle terminal line editing --- .../WorkNavigationAndTranscriptHelpers.swift | 74 ++++++++++++++++++ .../Views/Work/WorkTerminalEmulatorView.swift | 75 +++++++++++++++++++ apps/ios/ADETests/ADETests.swift | 15 ++++ 3 files changed, 164 insertions(+) diff --git a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift index 00ee4e136..bab76ef5a 100644 --- a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift @@ -43,6 +43,8 @@ private final class WorkTerminalTextReplay { private var column = 0 private var foreground: WorkANSIColor? private var bold = false + private var scrollTop = 0 + private var scrollBottom: Int? var text: String { renderedLines() @@ -140,6 +142,8 @@ private final class WorkTerminalTextReplay { column = 0 foreground = nil bold = false + scrollTop = 0 + scrollBottom = nil case "(", ")", "*", "+": if index < scalars.endIndex { index = scalars.index(after: index) @@ -248,6 +252,14 @@ private final class WorkTerminalTextReplay { } else if column < lines[row].count { lines[row].removeSubrange(column.. 0 { + scrollBottom = max(scrollTop, bottom - 1) + } else { + scrollBottom = nil + } + row = 0 + column = 0 + ensureCursor() + } + + private func activeScrollBottom() -> Int { + max(scrollTop, scrollBottom ?? max(lines.count - 1, row)) + } + private func applySGR(_ rawParams: [Int]) { let params = rawParams.isEmpty ? [0] : rawParams var index = params.startIndex diff --git a/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift b/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift index bc8906493..75b712e05 100644 --- a/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift +++ b/apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift @@ -160,6 +160,8 @@ private final class WorkTerminalScreen { private var cols = 88 private var foreground: UIColor? private var bold = false + private var scrollTop = 0 + private var scrollBottom: Int? private let maxLines = 4_000 func resize(cols: Int) -> Bool { @@ -176,6 +178,8 @@ private final class WorkTerminalScreen { column = 0 foreground = nil bold = false + scrollTop = 0 + scrollBottom = nil } func write(_ input: String) { @@ -401,6 +405,14 @@ private final class WorkTerminalScreen { } else if column < lines[row].count { lines[row].removeSubrange(column.. 0 { + scrollBottom = max(scrollTop, bottom - 1) + } else { + scrollBottom = nil + } + row = 0 + column = 0 + ensureCursor() + } + + private func activeScrollBottom() -> Int { + max(scrollTop, scrollBottom ?? max(lines.count - 1, row)) + } + private func applySGR(_ rawParams: [Int]) { let params = rawParams.isEmpty ? [0] : rawParams var index = params.startIndex diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index bd7655aef..e25698efe 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -39,6 +39,21 @@ final class ADETests: XCTestCase { XCTAssertEqual(zeroParam, "alZ") } + func testTerminalDisplayHandlesLineAndCharacterEditing() { + XCTAssertEqual( + sanitizeTerminalOutputForDisplay("one\ntwo\nthree\u{001B}[2A\u{001B}[G\u{001B}[M"), + "two\nthree" + ) + XCTAssertEqual( + sanitizeTerminalOutputForDisplay("one\nthree\u{001B}[1A\u{001B}[G\u{001B}[Ltwo"), + "two\none\nthree" + ) + XCTAssertEqual( + sanitizeTerminalOutputForDisplay("abcdef\u{001B}[1G\u{001B}[2P"), + "cdef" + ) + } + func testTerminalDisplayStripsAnsiColorAndBackspaces() { let output = sanitizeTerminalOutputForDisplay("\u{001B}[31merr\u{001B}[0mor\u{0008}k")