From c352632b74b679e3c45acd267282d20c962d5e53 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 7 Aug 2026 08:08:22 +0200 Subject: [PATCH] Launch a terminal instead of a chat from New workspace (#2941) * feat(app): launch a terminal from the New workspace screen New workspace only ever started a chat agent. Empty workspaces existed but were reachable only by submitting an empty composer, which nothing advertised, and terminals could not be started from this screen at all. People concluded Paseo did not support terminals. Adds a launch chip to the meta row, shaped from the same ComboboxTrigger as its neighbours, that chooses between Chat and a terminal. Terminal profiles mark where a typed prompt goes with a {{{prompt}}} sentinel in their command or args, substituted client-side before create_terminal_request, so the daemon learns nothing new and the protocol is untouched. A profile without the sentinel takes no input: the composer goes read-only and shows the exact command, so there is nothing to type wrong. Prompt forms come from each CLI's own --help. claude, codex, and pi take a trailing positional; opencode's positional is the project directory, so it uses --prompt=. Profiles predating the sentinel adopt the right form on read, keyed on the command's base name rather than the profile id, because profiles created in settings get generated ids. * fix(app): stop existing launchers passing the prompt sentinel literally Adoption made every caller of resolveTerminalProfiles see sentinel-bearing args, but only New workspace substituted them. Pinned targets and the workspace terminal menu forwarded profile.args straight through, so launching Claude from an existing workspace ran `claude "{{{prompt}}}"` with that text as the prompt. Every launcher now goes through resolveTerminalProfileLaunch. Surfaces with nowhere to type pass an empty prompt, which drops the prompt-only arg and gives back the bare command they had before. Also from the same review: - Adoption skipped when args lead with a subcommand. `opencode run` takes a positional message rather than --prompt, and `codex login` takes no prompt at all. Only the first arg is checked, so `pi --thinking high` still adopts. - Settings edits the persisted list again, not the adopted view. It writes the whole list back on any change, so resolving first baked read-time adoption into the user's config the moment they reordered a row. - A hidden draft no longer names a workspace. Text typed for a prompt-taking profile stayed in state after switching to a read-only one and still seeded the generated title and branch. - Windows .cmd escaping now has a round-trip test: a real cmd.exe runs a real .cmd shim that echoes its argv, covering metacharacters, an injection attempt, and newlines. The existing tests only asserted the command line we generate. - Fixed an ENOTEMPTY flake in the terminal suite. kill() only signals, so the shell was still writing into $ZDOTDIR while its directory was being removed. Cleanup waits for exit instead. * fix(app): keep the host and isolation accessible names stable Wiring the new tooltips also replaced these two accessible names with the tooltip copy, which broke `sidebar-project-grouping` at the host picker: its selector is `getByRole("button", { name: "Host", exact: true })` and the button had become "Choose the host". It timed out on both attempts in CI. An accessible name identifies a control; a tooltip explains it. Conflating them renamed a control that other surfaces address by name. The names go back to what they were and the tooltips keep the "Choose the ..." copy. * test(server): stop temp-dir teardown failing unrelated terminal tests The ENOTEMPTY flake survived both earlier attempts. Waiting for the session to exit shrank the window; retrying the remove did not close it either. The writes come from processes zsh forks on the way out, which outlive the pty and answer to nothing the test holds. Teardown is now best-effort and logs what it left behind. A temp directory we cannot remove is not a defect in the terminal code under test, and failing on it reports the race against whichever assertion ran last, which is exactly how this surfaced: as a flake in an unrelated zsh title test. 10 consecutive runs green. Also replaces a cast in the Windows .cmd helper with a real narrow, so a change to ResolvedTerminalCommand fails loudly there. Test files are excluded from typecheck (tsconfig.server.json), so a cast in one is never checked at all. * fix(app): keep terminal launch policy in its owning workflows Profile adoption now parses known global option values before deciding whether argv already contains a positional. Workspace naming is derived inside the terminal workflow so hidden launch-only drafts cannot affect branch or title generation. * fix(server): preserve cmd shim arguments through both parses npm command shims forward %*, so metacharacters lose one escape layer before the target process receives argv. Exercise the production node-pty string command line against a forwarding shim, and only tolerate the known ENOTEMPTY teardown race. * fix(server): stabilize Windows subprocess tests * test(server): isolate Windows argv recorder output * test(server): allow native watcher integration to settle --- .../new-workspace-launch-composer.spec.ts | 86 +++++ .../new-workspace-launch-memory.spec.ts | 101 +++++ .../new-workspace-launch-terminal.spec.ts | 101 +++++ .../support/helpers/new-workspace-launch.ts | 193 ++++++++++ packages/app/src/composer/index.tsx | 62 +++- packages/app/src/composer/input-mode.ts | 46 +++ packages/app/src/composer/input/input.tsx | 205 +++++++++-- .../preferences.test.ts | 29 ++ .../create-agent-preferences/preferences.ts | 9 + packages/app/src/i18n/resources/ar.ts | 19 +- packages/app/src/i18n/resources/en.ts | 19 +- packages/app/src/i18n/resources/es.ts | 19 +- packages/app/src/i18n/resources/fr.ts | 19 +- packages/app/src/i18n/resources/ja.ts | 19 +- packages/app/src/i18n/resources/ko.ts | 19 +- packages/app/src/i18n/resources/pt-BR.ts | 19 +- packages/app/src/i18n/resources/ru.ts | 19 +- packages/app/src/i18n/resources/zh-CN.ts | 19 +- .../new-workspace-launch/composer-state.ts | 103 ++++++ .../new-workspace-launch/launch-control.tsx | 261 +++++++++++++ .../src/new-workspace-launch/target.test.ts | 82 +++++ .../app/src/new-workspace-launch/target.ts | 49 +++ .../app/src/screens/new-workspace-screen.tsx | 348 ++++++++++++++---- .../screens/new-workspace-terminal.test.ts | 247 +++++++++++++ .../app/src/screens/new-workspace-terminal.ts | 88 +++++ .../app/src/screens/settings/host-page.tsx | 7 +- .../screens/workspace/workspace-screen.tsx | 7 +- packages/app/src/workspace-pins/run.test.ts | 16 +- packages/app/src/workspace-pins/run.ts | 9 +- .../protocol/src/terminal-profiles.test.ts | 209 ++++++++++- packages/protocol/src/terminal-profiles.ts | 288 ++++++++++++++- .../src/server/agent/agent-manager.test.ts | 15 +- ...it-service.observation.integration.test.ts | 2 +- packages/server/src/terminal/terminal.test.ts | 260 ++++++++++++- packages/server/src/terminal/terminal.ts | 86 ++++- packages/server/vitest.config.ts | 3 + 36 files changed, 2929 insertions(+), 154 deletions(-) create mode 100644 packages/app/e2e/browser/new-workspace-launch-composer.spec.ts create mode 100644 packages/app/e2e/browser/new-workspace-launch-memory.spec.ts create mode 100644 packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts create mode 100644 packages/app/e2e/support/helpers/new-workspace-launch.ts create mode 100644 packages/app/src/composer/input-mode.ts create mode 100644 packages/app/src/new-workspace-launch/composer-state.ts create mode 100644 packages/app/src/new-workspace-launch/launch-control.tsx create mode 100644 packages/app/src/new-workspace-launch/target.test.ts create mode 100644 packages/app/src/new-workspace-launch/target.ts create mode 100644 packages/app/src/screens/new-workspace-terminal.test.ts create mode 100644 packages/app/src/screens/new-workspace-terminal.ts diff --git a/packages/app/e2e/browser/new-workspace-launch-composer.spec.ts b/packages/app/e2e/browser/new-workspace-launch-composer.spec.ts new file mode 100644 index 000000000..f812d93ac --- /dev/null +++ b/packages/app/e2e/browser/new-workspace-launch-composer.spec.ts @@ -0,0 +1,86 @@ +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; +import { + expectNewWorkspaceDraft, + fillNewWorkspaceDraft, + openNewWorkspaceComposer, +} from "../support/helpers/new-workspace"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { + attachButton, + expectChatComposerActive, + expectTerminalComposerActive, + fillTerminalPrompt, + seedTerminalProfiles, + selectChatLaunch, + selectLaunchOption, + terminalPromptInput, + type TerminalProfile, + type TerminalProfileSeed, +} from "../support/helpers/new-workspace-launch"; + +const PROFILE: TerminalProfile = { + id: "e2e-composer-profile", + name: "Composer Profile", + command: "/bin/echo", + args: ["{{{prompt}}}"], +}; + +test.describe("New workspace: the composer is one control in two modes", () => { + let workspace: SeededWorkspace; + let profileSeed: TerminalProfileSeed; + + test.beforeEach(async () => { + workspace = await seedWorkspace({ repoPrefix: "launch-composer-" }); + profileSeed = await seedTerminalProfiles([PROFILE]); + }); + + test.afterEach(async () => { + await profileSeed.restore(); + await workspace?.cleanup(); + }); + + test("switching launch target does not leak draft text between chat and terminal, and drops the chat-only controls", async ({ + page, + }) => { + test.setTimeout(60_000); + await gotoAppShell(page); + await waitForSidebarHydration(page); + + await openNewWorkspaceComposer(page, { + projectKey: workspace.projectKey, + projectDisplayName: workspace.projectDisplayName, + }); + + await test.step("a chat draft does not leak into the terminal prompt", async () => { + await fillNewWorkspaceDraft(page, "a chat-only draft"); + await selectLaunchOption(page, PROFILE.id); + await expectTerminalComposerActive(page); + await expect(terminalPromptInput(page)).toHaveValue(""); + }); + + await test.step("the attachment button and mic are absent in terminal mode, and the submit button is inside the same surface", async () => { + await expect(attachButton(page)).toHaveCount(0); + await expect(page.getByRole("button", { name: "Start dictation" })).toHaveCount(0); + await expect( + page.locator('[data-testid="message-input-root"]:visible').getByRole("button", { + name: "Launch", + }), + ).toBeVisible(); + }); + + await test.step("a terminal draft does not leak back into chat, which keeps its own draft", async () => { + await fillTerminalPrompt(page, "a terminal-only draft"); + await selectChatLaunch(page); + await expectChatComposerActive(page); + await expectNewWorkspaceDraft(page, "a chat-only draft"); + }); + + await test.step("each side keeps its own draft across toggles, without either leaking into the other", async () => { + await selectLaunchOption(page, PROFILE.id); + await expectTerminalComposerActive(page); + await expect(terminalPromptInput(page)).toHaveValue("a terminal-only draft"); + }); + }); +}); diff --git a/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts b/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts new file mode 100644 index 000000000..dc15ed981 --- /dev/null +++ b/packages/app/e2e/browser/new-workspace-launch-memory.spec.ts @@ -0,0 +1,101 @@ +import { test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; +import { + openNewWorkspaceComposer, + submitNewWorkspacePrompt, +} from "../support/helpers/new-workspace"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { + expectChatLaunchSelected, + expectTerminalLaunchSelected, + expectTerminalOutputContains, + expectWorkspaceOpensWithTerminalTab, + fillTerminalPrompt, + patchTerminalProfiles, + seedTerminalProfiles, + selectChatLaunch, + selectLaunchOption, + submitTerminalLaunch, + type TerminalProfile, + type TerminalProfileSeed, +} from "../support/helpers/new-workspace-launch"; + +// `sleep` keeps the terminal alive long enough for the UI to attach and +// render output before the process exits — see new-workspace-launch-terminal.spec.ts. +const PROMPT_PROFILE: TerminalProfile = { + id: "e2e-memory-profile", + name: "Memory Profile", + command: "/bin/sh", + args: ["-c", 'echo remembered: "$0"; sleep 10', "{{{prompt}}}"], +}; + +function hasLeftNewWorkspaceRoute(url: URL): boolean { + return !/\/new(?:\?.*)?$/.test(`${url.pathname}${url.search}`); +} + +test.describe("New workspace: launch target memory", () => { + let workspace: SeededWorkspace; + let profileSeed: TerminalProfileSeed; + + test.beforeEach(async () => { + workspace = await seedWorkspace({ repoPrefix: "launch-memory-" }); + profileSeed = await seedTerminalProfiles([PROMPT_PROFILE]); + }); + + test.afterEach(async () => { + await profileSeed.restore(); + await workspace?.cleanup(); + }); + + test("remembers the launch target across visits, and a removed profile falls back to chat instead of a dead entry", async ({ + page, + }) => { + test.setTimeout(90_000); + await gotoAppShell(page); + await waitForSidebarHydration(page); + + const openNewWorkspace = () => + openNewWorkspaceComposer(page, { + projectKey: workspace.projectKey, + projectDisplayName: workspace.projectDisplayName, + }); + + await test.step("launching a terminal profile preselects it the next time New workspace opens", async () => { + await openNewWorkspace(); + await selectLaunchOption(page, PROMPT_PROFILE.id); + await fillTerminalPrompt(page, "remember me"); + await submitTerminalLaunch(page); + await expectWorkspaceOpensWithTerminalTab(page); + await expectTerminalOutputContains(page, "remembered: remember me"); + + await openNewWorkspace(); + await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name); + }); + + await test.step("switching to Chat and submitting preselects Chat the next time", async () => { + await selectChatLaunch(page); + await expectChatLaunchSelected(page); + await submitNewWorkspacePrompt(page, "hello from chat"); + // The chat handoff navigates asynchronously (draft -> created agent). Let + // it fully settle before moving on, so it can't fire a late redirect + // that races the next step's navigation. + await page.waitForURL(hasLeftNewWorkspaceRoute, { timeout: 30_000 }); + + await openNewWorkspace(); + await expectChatLaunchSelected(page); + }); + + await test.step("re-selecting the terminal profile, then removing it daemon-side, falls back to Chat rather than a dead entry", async () => { + await selectLaunchOption(page, PROMPT_PROFILE.id); + await fillTerminalPrompt(page, "second run"); + await submitTerminalLaunch(page); + await expectWorkspaceOpensWithTerminalTab(page); + + await patchTerminalProfiles([]); + + await openNewWorkspace(); + await expectChatLaunchSelected(page); + }); + }); +}); diff --git a/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts new file mode 100644 index 000000000..dd249cb03 --- /dev/null +++ b/packages/app/e2e/browser/new-workspace-launch-terminal.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; +import { openNewWorkspaceComposer } from "../support/helpers/new-workspace"; +import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client"; +import { + expectTerminalLaunchSelected, + expectTerminalOutputContains, + expectTerminalPreviewCommand, + expectWorkspaceOpensWithTerminalTab, + fillTerminalPrompt, + seedTerminalProfiles, + selectLaunchOption, + submitTerminalLaunch, + terminalLaunchSubmit, + terminalPromptInput, + type TerminalProfile, + type TerminalProfileSeed, +} from "../support/helpers/new-workspace-launch"; + +// Someone who wants a terminal agent instead of a chat: they pick a profile +// from the meta-row control, type a prompt, and the daemon actually spawns the +// resolved command with their prompt substituted in — no mocking Paseo's own +// code, the "profile" here is a real, harmless binary the daemon really runs. +// +// The sentinel becomes `$0` (a positional shell parameter, not string +// interpolation into the script source), so the prompt reaches the process +// exactly as typed with no injection risk. `sleep` keeps the terminal alive +// long enough for the UI to attach and render the output before it exits — +// a bare `echo` exits before the frontend subscribes to its output. +const PROMPT_PROFILE: TerminalProfile = { + id: "e2e-echo-prompt", + name: "Echo Prompt", + command: "/bin/sh", + args: ["-c", 'echo captured: "$0"; sleep 10', "{{{prompt}}}"], +}; + +// No sentinel anywhere: takes no prompt, composer goes read-only. +const BARE_PROFILE: TerminalProfile = { + id: "e2e-echo-bare", + name: "Bare Echo", + command: "/bin/sh", + args: ["-c", "echo bare-launch-static-line; sleep 10"], +}; + +test.describe("New workspace: launching a terminal", () => { + let workspace: SeededWorkspace; + let profileSeed: TerminalProfileSeed; + + test.beforeEach(async () => { + workspace = await seedWorkspace({ repoPrefix: "launch-terminal-" }); + profileSeed = await seedTerminalProfiles([PROMPT_PROFILE, BARE_PROFILE]); + }); + + test.afterEach(async () => { + await profileSeed.restore(); + await workspace?.cleanup(); + }); + + test("a profile that takes a prompt launches with it as its trailing argument; a profile without the sentinel launches bare from Launch", async ({ + page, + }) => { + test.setTimeout(90_000); + await gotoAppShell(page); + await waitForSidebarHydration(page); + + await test.step("pick a profile that takes a prompt, type one, and submit", async () => { + await openNewWorkspaceComposer(page, { + projectKey: workspace.projectKey, + projectDisplayName: workspace.projectDisplayName, + }); + await selectLaunchOption(page, PROMPT_PROFILE.id); + await expectTerminalLaunchSelected(page, PROMPT_PROFILE.name); + + await fillTerminalPrompt(page, "fix the flaky test"); + await submitTerminalLaunch(page); + }); + + await test.step("the workspace opens with a terminal tab, and the profile received the prompt", async () => { + await expectWorkspaceOpensWithTerminalTab(page); + await expectTerminalOutputContains(page, "captured: fix the flaky test"); + }); + + await test.step("a profile without the sentinel is read-only and shows the resolved command", async () => { + await openNewWorkspaceComposer(page, { + projectKey: workspace.projectKey, + projectDisplayName: workspace.projectDisplayName, + }); + await selectLaunchOption(page, BARE_PROFILE.id); + await expect(terminalPromptInput(page)).toHaveCount(0); + await expectTerminalPreviewCommand(page, "/bin/sh -c echo bare-launch-static-line; sleep 10"); + await expect(terminalLaunchSubmit(page)).toHaveText("Launch"); + }); + + await test.step("Launch starts the profile bare, with no prompt to send", async () => { + await submitTerminalLaunch(page); + await expectWorkspaceOpensWithTerminalTab(page); + await expectTerminalOutputContains(page, "bare-launch-static-line"); + }); + }); +}); diff --git a/packages/app/e2e/support/helpers/new-workspace-launch.ts b/packages/app/e2e/support/helpers/new-workspace-launch.ts new file mode 100644 index 000000000..284adad22 --- /dev/null +++ b/packages/app/e2e/support/helpers/new-workspace-launch.ts @@ -0,0 +1,193 @@ +import { expect, type Page } from "@playwright/test"; +import type { TerminalProfile } from "@getpaseo/protocol/messages"; +import { DEFAULT_TERMINAL_PROFILES } from "@getpaseo/protocol/terminal-profiles"; +import { connectNewWorkspaceDaemonClient } from "./new-workspace"; +import { waitForTerminalContent } from "./terminal-perf"; + +export type { TerminalProfile }; + +export interface TerminalProfileSeed { + /** Restores the daemon's terminalProfiles to what they were before seeding, and closes the client. */ + restore(): Promise; +} + +/** + * Overrides the connected host's terminal profiles for the duration of a + * test. The daemon has no "unset" for a patched config field, so restoring + * writes back either the exact previous array, or the shipped defaults when + * the host had never customized profiles — functionally identical to the + * absent state `resolveTerminalProfiles` would otherwise produce. + */ +export async function seedTerminalProfiles( + profiles: TerminalProfile[], +): Promise { + const client = await connectNewWorkspaceDaemonClient(); + const previous = await client.getDaemonConfig(); + await client.patchDaemonConfig({ terminalProfiles: profiles }); + return { + async restore() { + await client.patchDaemonConfig({ + terminalProfiles: previous.config.terminalProfiles ?? [...DEFAULT_TERMINAL_PROFILES], + }); + await client.close(); + }, + }; +} + +/** Mid-test profile changes (e.g. simulating a profile being removed). Does not manage restoration — pair with `seedTerminalProfiles` for that. */ +export async function patchTerminalProfiles(profiles: TerminalProfile[]): Promise { + const client = await connectNewWorkspaceDaemonClient(); + try { + await client.patchDaemonConfig({ terminalProfiles: profiles }); + } finally { + await client.close(); + } +} + +// ─── Locators ────────────────────────────────────────────────────────────── + +/** The single meta-row chip that chooses chat or a terminal. */ +export function launchTrigger(page: Page) { + return page.getByTestId("new-workspace-launch-trigger"); +} + +export function launchMenu(page: Page) { + return page.getByTestId("new-workspace-launch-menu"); +} + +export function launchOption(page: Page, optionId: string) { + return page.getByTestId(`new-workspace-launch-option-${optionId}`); +} + +export function manageProfilesOption(page: Page) { + return page.getByTestId("new-workspace-launch-manage-profiles"); +} + +// Chat and terminal are the same composer in two modes, so they share +// `message-input-root`. What tells them apart is which affordances that mode +// renders: chat has the attachment button, terminal has the Launch button. +export function composerRoot(page: Page) { + return page.locator('[data-testid="message-input-root"]:visible'); +} + +// Scoped to the visible composer: a workspace opened earlier in the test keeps +// its own composer mounted but hidden in the deck, so an unscoped testid +// matches two elements and trips strict mode. +export function attachButton(page: Page) { + return composerRoot(page).getByTestId("message-input-attach-button"); +} + +export function terminalPromptInput(page: Page) { + return composerRoot(page).getByRole("textbox", { name: "Terminal prompt" }); +} + +/** The static command shown when the selected profile takes no prompt. */ +export function terminalCommandPreview(page: Page) { + return composerRoot(page).getByTestId("composer-readonly-content"); +} + +export function terminalLaunchSubmit(page: Page) { + return composerRoot(page).getByTestId("new-workspace-launch-submit"); +} + +// ─── Actions ─────────────────────────────────────────────────────────────── + +export async function openLaunchMenu(page: Page): Promise { + await launchTrigger(page).click(); + await expect(launchMenu(page)).toBeVisible({ timeout: 30_000 }); +} + +/** `optionId` is `chat`, `blank`, or a terminal profile id. */ +export async function selectLaunchOption(page: Page, optionId: string): Promise { + await openLaunchMenu(page); + const option = launchOption(page, optionId); + await expect(option).toBeVisible({ timeout: 30_000 }); + await option.click(); + await expect(launchMenu(page)).toHaveCount(0); +} + +export async function selectChatLaunch(page: Page): Promise { + await selectLaunchOption(page, "chat"); +} + +export async function fillTerminalPrompt(page: Page, text: string): Promise { + const input = terminalPromptInput(page); + await expect(input).toBeVisible({ timeout: 30_000 }); + await input.fill(text); +} + +export async function submitTerminalLaunch(page: Page): Promise { + const submit = terminalLaunchSubmit(page); + await expect(submit).toBeEnabled({ timeout: 30_000 }); + await submit.click(); +} + +// ─── Assertions ──────────────────────────────────────────────────────────── + +export async function expectChatComposerActive(page: Page): Promise { + await expect(composerRoot(page)).toBeVisible({ timeout: 30_000 }); + await expect(attachButton(page)).toBeVisible({ timeout: 30_000 }); + await expect(terminalLaunchSubmit(page)).toHaveCount(0); +} + +export async function expectTerminalComposerActive(page: Page): Promise { + await expect(composerRoot(page)).toBeVisible({ timeout: 30_000 }); + await expect(terminalLaunchSubmit(page)).toBeVisible({ timeout: 30_000 }); + await expect(attachButton(page)).toHaveCount(0); +} + +export async function expectLaunchTriggerLabel(page: Page, label: string): Promise { + await expect(launchTrigger(page)).toContainText(label); +} + +export async function expectChatLaunchSelected(page: Page): Promise { + await expectLaunchTriggerLabel(page, "Chat"); + await expectChatComposerActive(page); +} + +export async function expectTerminalLaunchSelected( + page: Page, + profileLabel: string, +): Promise { + await expectLaunchTriggerLabel(page, profileLabel); + await expectTerminalComposerActive(page); +} + +export async function expectTerminalPreviewCommand(page: Page, command: string): Promise { + const preview = terminalCommandPreview(page); + await expect(preview).toBeVisible({ timeout: 30_000 }); + await expect(preview).toHaveText(command); +} + +function terminalContentIncludes(expected: string) { + return (text: string) => text.includes(expected); +} + +/** Waits for the spawned terminal's rendered output to contain `expected`. */ +export async function expectTerminalOutputContains( + page: Page, + expected: string, + timeoutMs = 30_000, +): Promise { + await waitForTerminalContent(page, terminalContentIncludes(expected), timeoutMs); +} + +/** + * Waits for the New workspace submit to land on a workspace with a terminal + * tab open, and returns the terminal id. Scoped to visible elements: earlier + * workspace decks stay mounted (offscreen) for fast switching, so an + * unscoped terminal-surface locator can match more than one. + */ +export async function expectWorkspaceOpensWithTerminalTab(page: Page): Promise { + const terminalTab = page.locator('[data-testid^="workspace-tab-terminal_"]:visible'); + await expect(terminalTab.first()).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="terminal-surface"]:visible').first()).toBeVisible({ + timeout: 30_000, + }); + const testId = await terminalTab.first().getAttribute("data-testid"); + const terminalId = testId?.replace("workspace-tab-terminal_", "") ?? ""; + if (!terminalId) { + throw new Error(`Could not parse terminal id from tab testID: ${testId}`); + } + return terminalId; +} diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index 22873a2d2..02e8d6d1b 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -89,6 +89,7 @@ import { persistAttachmentFromFileUri, } from "@/attachments/service"; import { resolveAgentControlsMode } from "@/composer/agent-controls/mode"; +import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher"; @@ -186,7 +187,21 @@ function resolveCompactLayout(override: boolean | undefined, formFactor: boolean return override ?? formFactor; } -function resolveMessagePlaceholder(isDesktopWebBreakpoint: boolean, t: TFunction): string { +function resolveMessagePlaceholder( + inputMode: ComposerInputMode, + isDesktopWebBreakpoint: boolean, + t: TFunction, + override: string | undefined, +): string { + // A terminal placeholder names what it launches ("Prompt Codex", "Run a + // command"), which depends on the selected profile. Only the caller knows + // that, so it wins when supplied. + if (override !== undefined) { + return override; + } + if (inputMode === "terminal") { + return t("composer.placeholders.terminal"); + } return isDesktopWebBreakpoint ? t("composer.placeholders.desktop") : t("composer.placeholders.mobile"); @@ -280,10 +295,12 @@ interface RenderLeftContentArgs { focusInput: () => void; isCompactLayout: boolean; isPaneFocused: boolean; + showAgentControls: boolean; } -function renderLeftContent(args: RenderLeftContentArgs): ReactElement { +function renderLeftContent(args: RenderLeftContentArgs): ReactElement | null { const { agentControls, agentId, serverId, focusInput, isCompactLayout, isPaneFocused } = args; + if (!args.showAgentControls) return null; if (resolveAgentControlsMode(agentControls) === "draft" && agentControls) { return ; } @@ -858,6 +875,18 @@ interface ComposerProps { externalKeyboardShift?: boolean; /** Optional panel/container layout breakpoint. Defaults to the screen breakpoint. */ isCompactLayout?: boolean; + /** + * What this composer is for. Terminal drops the chat-agent affordances and + * uses the terminal font; see `@/composer/input-mode`. Callers set the mode + * and nothing else — never branch on it at the call site. + */ + inputMode?: ComposerInputMode; + /** Renders `value` as static text on the same surface, for content there is nothing to type into. */ + readOnly?: boolean; + /** Replaces the submit icon with this label, still inside the composer's own toolbar row. */ + submitLabel?: string; + /** Overrides the mode's default placeholder, for text only the caller can build. */ + placeholder?: string; } const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; @@ -942,6 +971,7 @@ interface ComposerRightControlsSlotProps extends ComposerVoiceModeButtonProps { isAgentRunning: boolean; hasSendableContent: boolean; isCompact: boolean; + showVoice: boolean; } function ComposerRightControlsSlot({ @@ -950,11 +980,12 @@ function ComposerRightControlsSlot({ isAgentRunning, hasSendableContent, isCompact, + showVoice, ...voiceProps }: ComposerRightControlsSlotProps) { const hideVoiceForCompactInput = isCompact && hasSendableContent; const showVoiceModeButton = - !isVoiceModeForAgent && hasAgent && !isAgentRunning && !hideVoiceForCompactInput; + showVoice && !isVoiceModeForAgent && hasAgent && !isAgentRunning && !hideVoiceForCompactInput; if (!showVoiceModeButton) return null; return ( @@ -1043,7 +1074,12 @@ export function Composer({ inputWrapperStyle, externalKeyboardShift, isCompactLayout: isCompactLayoutOverride, + inputMode = "chat", + readOnly = false, + submitLabel, + placeholder, }: ComposerProps) { + const mode = resolveComposerInputMode(inputMode); const { t } = useTranslation(); const buttonIconSize = resolveComposerButtonIconSize(); const client = useHostRuntimeClient(serverId); @@ -1076,7 +1112,7 @@ export function Composer({ const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor); const isDesktopWebBreakpoint = resolveIsDesktopWebBreakpoint(isCompactFormFactor); const isDesktopLayout = resolveIsDesktopWebBreakpoint(isCompactLayout); - const messagePlaceholder = resolveMessagePlaceholder(isDesktopLayout, t); + const messagePlaceholder = resolveMessagePlaceholder(inputMode, isDesktopLayout, t, placeholder); const userInput = value; const setUserInput = onChangeText; const workspaceAttachments = useWorkspaceAttachmentsForScopes(attachmentScopeKeys); @@ -1743,6 +1779,7 @@ export function Composer({ isAgentRunning={isAgentRunning} hasSendableContent={hasSendableContent} isCompact={isCompactLayout} + showVoice={mode.showVoice} buttonIconSize={buttonIconSize} handleToggleRealtimeVoice={handleToggleRealtimeVoice} isConnected={isConnected} @@ -1762,6 +1799,7 @@ export function Composer({ isCompactLayout, isVoiceModeForAgent, isVoiceSwitching, + mode.showVoice, realtimeVoiceButtonStyle, t, voiceToggleKeys, @@ -1925,8 +1963,17 @@ export function Composer({ focusInput, isCompactLayout, isPaneFocused, + showAgentControls: mode.showAgentControls, }), - [agentControls, agentId, focusInput, isCompactLayout, isPaneFocused, serverId], + [ + agentControls, + agentId, + focusInput, + isCompactLayout, + isPaneFocused, + mode.showAgentControls, + serverId, + ], ); const handleAttachButtonRef = useCallback((node: View | null) => { @@ -2051,7 +2098,7 @@ export function Composer({ const githubEmptyText = githubSearchResultsQuery.isFetching ? t("composer.github.searching") : t("composer.github.noResults"); - const autocompleteVisible = autocomplete.isVisible && isPaneFocused; + const autocompleteVisible = autocomplete.isVisible && isPaneFocused && mode.showAutocomplete; return ( @@ -2118,6 +2165,9 @@ export function Composer({ onHeightChange={onComposerHeightChange} inputWrapperStyle={inputWrapperStyle} attachmentSlot={attachmentTray} + inputMode={inputMode} + readOnly={readOnly} + submitLabel={submitLabel} /> = { + chat: { + showAttachments: true, + showVoice: true, + showAutocomplete: true, + showAgentControls: true, + isMonospace: false, + accessibilityLabelKey: "composer.input.accessibilityLabel", + }, + terminal: { + showAttachments: false, + showVoice: false, + showAutocomplete: false, + showAgentControls: false, + isMonospace: true, + accessibilityLabelKey: "composer.input.terminalAccessibilityLabel", + }, +}; + +export function resolveComposerInputMode(mode: ComposerInputMode): ComposerInputModePresentation { + return PRESENTATION_BY_MODE[mode]; +} diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index dd34de73a..7d85be91d 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -55,6 +55,7 @@ import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { isWeb } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useComposerHeightMirror } from "./height-mirror"; +import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode"; import { resolveSendTooltipLabel, resolveSubmitAccessibilityLabel, @@ -142,6 +143,12 @@ export interface MessageInputProps { inputWrapperStyle?: import("react-native").ViewStyle; /** Content rendered inside the bordered input surface, above the text input (e.g. attachment pills). */ attachmentSlot?: React.ReactNode; + /** What this composer is for. See `@/composer/input-mode` for what each mode implies. */ + inputMode?: ComposerInputMode; + /** Renders `value` as static text on the same surface, for content there is nothing to type into. */ + readOnly?: boolean; + /** Replaces the submit icon with this label, still inside the composer's own toolbar row. */ + submitLabel?: string; } export interface MessageInputRef { @@ -220,6 +227,7 @@ function AttachmentMenuList({ items }: { items: AttachmentMenuItem[] }) { } function AttachmentDropdown({ + visible, isConnected, disabled, attachButtonStyle, @@ -227,6 +235,7 @@ function AttachmentDropdown({ attachmentMenuItems, addAttachmentLabel, }: { + visible: boolean; isConnected: boolean; disabled: boolean; attachButtonStyle: React.ComponentProps["style"]; @@ -235,6 +244,7 @@ function AttachmentDropdown({ addAttachmentLabel: string; }) { const isButtonDisabled = !isConnected || disabled; + if (!visible) return null; return ( @@ -323,15 +333,20 @@ function SendTooltipBody({ function SendButtonContent({ isSubmitLoading, submitIcon, + submitLabel, buttonIconSize, }: { isSubmitLoading: boolean; submitIcon: "arrow" | "return"; + submitLabel: string | undefined; buttonIconSize: number; }) { if (isSubmitLoading) { return ; } + if (submitLabel) { + return {submitLabel}; + } if (submitIcon === "return") { return ; } @@ -570,7 +585,75 @@ function FocusHint({ ); } +interface ComposerTextSurfaceProps { + readOnly: boolean; + value: string; + textInputRef: React.Ref; + textInputStyle: React.ComponentProps["style"]; + readOnlyTextStyle: React.ComponentProps["style"]; + placeholder: string; + accessibilityLabel: string; + onChangeText: (text: string) => void; + onFocus: () => void; + onBlur: () => void; + editable: boolean; + scrollEnabled: boolean; + autoFocus: boolean; + onContentSizeChange: (event: NativeSyntheticEvent) => void; + onKeyPress: ((event: WebTextInputKeyPressEvent) => void) | undefined; + onSelectionChange: (event: NativeSyntheticEvent) => void; + focusHintVisible: boolean; + focusInputKeys: ShortcutChord | null | undefined; + focusHintLabel: string; +} + +/** + * The composer's content: an editable input, or static text when there is + * nothing to type. Both sit in the same bordered surface, so read-only is a + * state of this composer rather than a second one. + */ +function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElement { + if (props.readOnly) { + return ( + + + {props.value} + + + ); + } + return ( + + + + + ); +} + function VoiceButtonTooltip({ + visible, onVoicePress, isDictationStartEnabled, voiceButtonAccessibilityLabel, @@ -581,6 +664,7 @@ function VoiceButtonTooltip({ voiceMuteToggleKeys, dictationToggleKeys, }: { + visible: boolean; onVoicePress: () => void; isDictationStartEnabled: boolean; voiceButtonAccessibilityLabel: string; @@ -592,6 +676,7 @@ function VoiceButtonTooltip({ dictationToggleKeys: ShortcutChord | null | undefined; }) { const shortcut = isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys; + if (!visible) return null; return ( ["style"]; isSubmitLoading: boolean; submitIcon: "arrow" | "return"; + submitLabel: string | undefined; submitButtonTestID: string | undefined; buttonIconSize: number; sendKeys: ShortcutChord | null | undefined; @@ -653,6 +740,7 @@ function SendButtonTooltip({ @@ -954,6 +1042,9 @@ interface ResolvedMessageInputProps { onHeightChange: ((height: number) => void) | undefined; inputWrapperStyle: import("react-native").ViewStyle | undefined; attachmentSlot: React.ReactNode; + inputMode: ComposerInputMode; + readOnly: boolean; + submitLabel: string | undefined; } function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInputProps { @@ -997,6 +1088,9 @@ function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInpu onHeightChange: props.onHeightChange, inputWrapperStyle: props.inputWrapperStyle, attachmentSlot: props.attachmentSlot, + inputMode: props.inputMode ?? "chat", + readOnly: props.readOnly ?? false, + submitLabel: props.submitLabel, }; } @@ -1048,7 +1142,11 @@ export const MessageInput = forwardRef( onHeightChange, inputWrapperStyle, attachmentSlot, + inputMode, + readOnly, + submitLabel, } = resolveMessageInputProps(props); + const mode = resolveComposerInputMode(inputMode); const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); const { height: windowHeight } = useWindowDimensions(); @@ -1546,18 +1644,38 @@ export const MessageInput = forwardRef( const inputWrapperCombinedStyle = useMemo( () => [ styles.inputWrapper, + readOnly && styles.inputWrapperReadOnly, inputWrapperStyle, { opacity: surfacePresentation.input.opacity }, ], - [inputWrapperStyle, surfacePresentation.input.opacity], + [inputWrapperStyle, readOnly, surfacePresentation.input.opacity], ); + // `withUnistyles` maps this component's `style` into a `.hash > *` child + // rule, which ties on specificity with react-native-web's own + // `.css-textinput-*` class and loses on source order — so a themed + // `fontFamily` here is silently dropped while every other property lands. + // An inline style outranks both classes. See docs/unistyles.md. const textInputStyle = useMemo( - () => [styles.textInput, computeTextInputHeightStyle(inputHeight, maxInputHeight)], - [inputHeight, maxInputHeight], + () => [ + styles.textInput, + mode.isMonospace && styles.textInputMonospace, + computeTextInputHeightStyle(inputHeight, maxInputHeight), + ], + [inputHeight, maxInputHeight, mode.isMonospace], + ); + // Static content has no textarea to mirror, so it grows with its own text + // instead of the measured input height. + const readOnlyTextStyle = useMemo( + () => [styles.textInput, mode.isMonospace && styles.textInputMonospace, styles.readOnlyText], + [mode.isMonospace], ); const sendButtonCombinedStyle = useMemo( - () => [styles.sendButton, isSendButtonDisabled && styles.buttonDisabled], - [isSendButtonDisabled], + () => [ + styles.sendButton, + submitLabel ? styles.sendButtonLabeled : undefined, + isSendButtonDisabled && styles.buttonDisabled, + ], + [isSendButtonDisabled, submitLabel], ); const overlayContainerStyle = useMemo( () => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }], @@ -1597,40 +1715,36 @@ export const MessageInput = forwardRef( > {attachmentSlot} {/* Text input */} - - = maxInputHeight : true} - onContentSizeChange={handleContentSizeChange} - editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled} - onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined} - onSelectionChange={handleSelectionChange} - autoFocus={isWeb && autoFocus} - /> - - + = maxInputHeight : true} + autoFocus={isWeb && autoFocus} + onContentSizeChange={handleContentSizeChange} + onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined} + onSelectionChange={handleSelectionChange} + focusHintVisible={isWeb && isPaneFocused && !isInputFocused && !value} + focusInputKeys={focusInputKeys} + focusHintLabel={t("composer.input.focusHint", { + shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "", + })} + /> {/* Button row */} {/* Toolbar left: attachment button + agent controls */} ( {beforeVoiceContent} ( sendButtonCombinedStyle={sendButtonCombinedStyle} isSubmitLoading={isSubmitLoading} submitIcon={submitIcon} + submitLabel={submitLabel} submitButtonTestID={submitButtonTestID} buttonIconSize={buttonIconSize} sendKeys={DEFAULT_SEND_KEYS} @@ -1731,6 +1847,11 @@ const styles = StyleSheet.create((theme: Theme) => ({ } : {}), }, + // Dotted says "this surface is the same box, but there is nothing to type + // into it" without swapping the border colour, which reads as an error state. + inputWrapperReadOnly: { + borderStyle: "dotted", + }, textInputScrollWrapper: { position: "relative", }, @@ -1756,6 +1877,13 @@ const styles = StyleSheet.create((theme: Theme) => ({ } as object) : {}), }, + textInputMonospace: { + fontFamily: theme.fontFamily.mono, + }, + readOnlyText: { + minHeight: MIN_INPUT_HEIGHT, + color: theme.colors.foregroundMuted, + }, buttonRow: { flexDirection: "row", alignItems: "flex-end", @@ -1808,6 +1936,17 @@ const styles = StyleSheet.create((theme: Theme) => ({ justifyContent: "center", marginLeft: theme.spacing[1], }, + sendButtonLabeled: { + width: "auto", + minWidth: 28, + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.full, + }, + sendButtonLabel: { + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.medium, + color: theme.colors.accentForeground, + }, iconButtonHovered: { backgroundColor: theme.colors.surface2, }, diff --git a/packages/app/src/create-agent-preferences/preferences.test.ts b/packages/app/src/create-agent-preferences/preferences.test.ts index 5b73d51c4..8ba96a4fc 100644 --- a/packages/app/src/create-agent-preferences/preferences.test.ts +++ b/packages/app/src/create-agent-preferences/preferences.test.ts @@ -159,4 +159,33 @@ describe("create agent preferences", () => { it("rejects an unknown isolation value as invalid stored preferences", () => { expect(parseFormPreferences({ provider: "codex", isolation: "sandbox" })).toEqual({}); }); + + it("persists and reloads a terminal launch target", async () => { + const storage = new FakeCreateAgentPreferenceStorage(); + const preferences = new CreateAgentPreferencesService(storage); + + const save = preferences.update({ launchTarget: { kind: "terminal", profileId: "claude" } }); + await storage.nextWrite(); + storage.finishOldestWrite(); + await save; + + expect(storage.savedPreferences()).toEqual({ + launchTarget: { kind: "terminal", profileId: "claude" }, + }); + expect(await new CreateAgentPreferencesService(storage).load()).toEqual({ + launchTarget: { kind: "terminal", profileId: "claude" }, + }); + }); + + it("treats stored preferences without a launch target as undefined, defaulting to chat", () => { + expect(parseFormPreferences({ provider: "codex" }).launchTarget).toBeUndefined(); + }); + + it("rejects a terminal launch target missing a profileId as invalid stored preferences", () => { + expect(parseFormPreferences({ launchTarget: { kind: "terminal" } })).toEqual({}); + }); + + it("rejects an unknown launch target kind as invalid stored preferences", () => { + expect(parseFormPreferences({ launchTarget: { kind: "shell" } })).toEqual({}); + }); }); diff --git a/packages/app/src/create-agent-preferences/preferences.ts b/packages/app/src/create-agent-preferences/preferences.ts index 72c013a51..2c8cfd2be 100644 --- a/packages/app/src/create-agent-preferences/preferences.ts +++ b/packages/app/src/create-agent-preferences/preferences.ts @@ -22,6 +22,11 @@ const providerPreferencesSchema = z.object({ featureValues: z.record(z.string(), z.unknown()).optional(), }); +const launchTargetSchema = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("chat") }), + z.object({ kind: z.literal("terminal"), profileId: z.string() }), +]); + const formPreferencesSchema = z.object({ provider: z.string().optional(), providerPreferences: z.record(z.string(), providerPreferencesSchema).optional(), @@ -34,10 +39,14 @@ const formPreferencesSchema = z.object({ ) .optional(), isolation: z.enum(["local", "worktree"]).optional(), + // What the New workspace composer submits to: the chat agent (default) or a + // terminal profile. See `@/new-workspace-launch` for resolution/fallback. + launchTarget: launchTargetSchema.optional(), }); export type ProviderPreferences = z.infer; export type FormPreferences = z.infer; +export type LaunchTarget = z.infer; export const DEFAULT_FORM_PREFERENCES: FormPreferences = {}; diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index c4f5853e2..c0732e5ba 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -81,9 +81,11 @@ export const ar: TranslationResources = { desktop: "أرسل رسالة إلى الوكيل أو ضع علامة على @files أو استخدم /commands و /skills", mobile: "الرسالة، @files ، /commands", fallback: "رسالة...", + terminal: "Prompt", }, input: { accessibilityLabel: "وكيل الرسائل...", + terminalAccessibilityLabel: "Terminal prompt", focusHint: "{{shortcut}}للتركيز", addAttachment: "إضافة مرفق", interruptAgent: "عامل المقاطعة", @@ -1052,15 +1054,30 @@ export const ar: TranslationResources = { composerStateRequired: "حالة الملحن مطلوبة", selectModel: "اختر نموذجا", }, + tooltips: { + project: "Choose the project", + host: "Choose the host", + isolation: "Choose the isolation level", + startingRef: "اختر من أين تبدأ", + launch: "Choose what to launch", + }, refPicker: { startingRef: "بدء المرجع", - chooseStart: "اختر من أين تبدأ", intoBase: "إلى {{baseRef}}", searching: "جارٍ البحث...", noMatchingRefs: "لا توجد مراجع مطابقة.", searchPlaceholder: "بحث الفروع والعلاقات العامة", title: "ابدأ من", }, + launch: { + title: "What to launch", + chat: "Chat", + terminal: "Terminal", + manageProfiles: "Manage profiles", + submit: "Launch", + promptPlaceholder: "Prompt {{name}}", + commandPlaceholder: "Run a command, or leave empty for a blank terminal", + }, }, desktop: { quitting: { diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index e1a15bef2..31857bb63 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -79,9 +79,11 @@ export const en = { desktop: "Message the agent, tag @files, or use /commands and /skills", mobile: "Message, @files, /commands", fallback: "Message...", + terminal: "Prompt", }, input: { accessibilityLabel: "Message agent...", + terminalAccessibilityLabel: "Terminal prompt", focusHint: "{{shortcut}} to focus", addAttachment: "Add attachment", interruptAgent: "Interrupt agent", @@ -1062,15 +1064,30 @@ export const en = { composerStateRequired: "Composer state is required", selectModel: "Select a model", }, + tooltips: { + project: "Choose the project", + host: "Choose the host", + isolation: "Choose the isolation level", + startingRef: "Choose where to start from", + launch: "Choose what to launch", + }, refPicker: { startingRef: "Starting ref", - chooseStart: "Choose where to start from", intoBase: "into {{baseRef}}", searching: "Searching...", noMatchingRefs: "No matching refs.", searchPlaceholder: "Search branches and PRs", title: "Start from", }, + launch: { + title: "What to launch", + chat: "Chat", + terminal: "Terminal", + manageProfiles: "Manage profiles", + submit: "Launch", + promptPlaceholder: "Prompt {{name}}", + commandPlaceholder: "Run a command, or leave empty for a blank terminal", + }, }, desktop: { quitting: { diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 9b2d11712..aa03f762d 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -81,9 +81,11 @@ export const es: TranslationResources = { desktop: "Envíe un mensaje al agente, etiquete@fileso use/commandsy/skills", mobile: "Mensaje,@files,/commands", fallback: "Mensaje...", + terminal: "Prompt", }, input: { accessibilityLabel: "Agente de mensajes...", + terminalAccessibilityLabel: "Terminal prompt", focusHint: "{{shortcut}}para enfocar", addAttachment: "Agregar archivo adjunto", interruptAgent: "agente de interrupción", @@ -1083,15 +1085,30 @@ export const es: TranslationResources = { composerStateRequired: "Se requiere el estado del compositor", selectModel: "Selecciona un modelo", }, + tooltips: { + project: "Choose the project", + host: "Choose the host", + isolation: "Choose the isolation level", + startingRef: "Elige por dónde empezar", + launch: "Choose what to launch", + }, refPicker: { startingRef: "Árbitro inicial", - chooseStart: "Elige por dónde empezar", intoBase: "en {{baseRef}}", searching: "Búsqueda...", noMatchingRefs: "No hay árbitros coincidentes.", searchPlaceholder: "Buscar sucursales y relaciones públicas", title: "Empezar desde", }, + launch: { + title: "What to launch", + chat: "Chat", + terminal: "Terminal", + manageProfiles: "Manage profiles", + submit: "Launch", + promptPlaceholder: "Prompt {{name}}", + commandPlaceholder: "Run a command, or leave empty for a blank terminal", + }, }, desktop: { quitting: { diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index dcc002acd..b9e96aa87 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -83,9 +83,11 @@ export const fr: TranslationResources = { desktop: "Envoyez un message à l'agent, marquez@filesou utilisez/commandset/skills", mobile: "Message,@files,/commands", fallback: "Message...", + terminal: "Prompt", }, input: { accessibilityLabel: "Agent de messagerie...", + terminalAccessibilityLabel: "Terminal prompt", focusHint: "{{shortcut}}pour se concentrer", addAttachment: "Ajouter une pièce jointe", interruptAgent: "Agent d'interruption", @@ -1082,15 +1084,30 @@ export const fr: TranslationResources = { composerStateRequired: "L'état du compositeur est requis", selectModel: "Sélectionnez un modèle", }, + tooltips: { + project: "Choose the project", + host: "Choose the host", + isolation: "Choose the isolation level", + startingRef: "Choisissez par où commencer", + launch: "Choose what to launch", + }, refPicker: { startingRef: "Réf de départ", - chooseStart: "Choisissez par où commencer", intoBase: "dans {{baseRef}}", searching: "Recherche...", noMatchingRefs: "Aucune référence correspondante.", searchPlaceholder: "Rechercher des succursales et des PR", title: "Commencer à partir de", }, + launch: { + title: "What to launch", + chat: "Chat", + terminal: "Terminal", + manageProfiles: "Manage profiles", + submit: "Launch", + promptPlaceholder: "Prompt {{name}}", + commandPlaceholder: "Run a command, or leave empty for a blank terminal", + }, }, desktop: { quitting: { diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index bf4b81db2..9478fb7be 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -81,9 +81,11 @@ export const ja: TranslationResources = { desktop: "エージェントにメッセージ、@ファイル、/コマンドや/スキルを入力", mobile: "メッセージ、@ファイル、/コマンド", fallback: "メッセージ...", + terminal: "Prompt", }, input: { accessibilityLabel: "エージェントにメッセージ...", + terminalAccessibilityLabel: "Terminal prompt", focusHint: "{{shortcut}}でフォーカス", addAttachment: "添付ファイルを追加", interruptAgent: "エージェントを中断", @@ -1063,15 +1065,30 @@ export const ja: TranslationResources = { composerStateRequired: "コンポーザーの状態が必要です", selectModel: "モデルを選択してください", }, + tooltips: { + project: "Choose the project", + host: "Choose the host", + isolation: "Choose the isolation level", + startingRef: "開始点を選択", + launch: "Choose what to launch", + }, refPicker: { startingRef: "開始Ref", - chooseStart: "開始点を選択", intoBase: "{{baseRef}}に", searching: "検索中...", noMatchingRefs: "一致するRefがありません。", searchPlaceholder: "ブランチとPRを検索", title: "開始点", }, + launch: { + title: "What to launch", + chat: "Chat", + terminal: "Terminal", + manageProfiles: "Manage profiles", + submit: "Launch", + promptPlaceholder: "Prompt {{name}}", + commandPlaceholder: "Run a command, or leave empty for a blank terminal", + }, }, desktop: { quitting: { diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 45ecaaf2f..23988147a 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -81,9 +81,11 @@ export const ko: TranslationResources = { desktop: "에이전트에게 메시지를 보내거나 @files 태그, /commands, /skills를 사용하세요", mobile: "메시지, @files, /commands", fallback: "메시지...", + terminal: "Prompt", }, input: { accessibilityLabel: "에이전트에게 메시지...", + terminalAccessibilityLabel: "Terminal prompt", focusHint: "{{shortcut}}로 포커스", addAttachment: "첨부 추가", interruptAgent: "에이전트 중단", @@ -1059,15 +1061,30 @@ export const ko: TranslationResources = { composerStateRequired: "작성기 상태가 필요합니다", selectModel: "모델을 선택하세요", }, + tooltips: { + project: "Choose the project", + host: "Choose the host", + isolation: "Choose the isolation level", + startingRef: "시작 위치를 선택하세요", + launch: "Choose what to launch", + }, refPicker: { startingRef: "시작 ref", - chooseStart: "시작 위치를 선택하세요", intoBase: "{{baseRef}}(으)로", searching: "검색 중...", noMatchingRefs: "일치하는 ref가 없습니다.", searchPlaceholder: "브랜치와 PR 검색", title: "시작 위치", }, + launch: { + title: "What to launch", + chat: "Chat", + terminal: "Terminal", + manageProfiles: "Manage profiles", + submit: "Launch", + promptPlaceholder: "Prompt {{name}}", + commandPlaceholder: "Run a command, or leave empty for a blank terminal", + }, }, desktop: { quitting: { diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index fd94d5324..5234f475c 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -81,9 +81,11 @@ export const ptBR: TranslationResources = { desktop: "Envie uma mensagem ao agente, marque @files ou use /commands e /skills", mobile: "Mensagem, @files, /commands", fallback: "Mensagem...", + terminal: "Prompt", }, input: { accessibilityLabel: "Enviar mensagem ao agente...", + terminalAccessibilityLabel: "Terminal prompt", focusHint: "{{shortcut}} para focar", addAttachment: "Adicionar anexo", interruptAgent: "Interromper agente", @@ -1074,15 +1076,30 @@ export const ptBR: TranslationResources = { composerStateRequired: "O estado do composer é obrigatório", selectModel: "Selecione um modelo", }, + tooltips: { + project: "Choose the project", + host: "Choose the host", + isolation: "Choose the isolation level", + startingRef: "Escolha de onde começar", + launch: "Choose what to launch", + }, refPicker: { startingRef: "Ref inicial", - chooseStart: "Escolha de onde começar", intoBase: "em {{baseRef}}", searching: "Buscando...", noMatchingRefs: "Nenhuma ref correspondente.", searchPlaceholder: "Buscar branches e PRs", title: "Começar de", }, + launch: { + title: "What to launch", + chat: "Chat", + terminal: "Terminal", + manageProfiles: "Manage profiles", + submit: "Launch", + promptPlaceholder: "Prompt {{name}}", + commandPlaceholder: "Run a command, or leave empty for a blank terminal", + }, }, desktop: { quitting: { diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index e853435eb..d8c7a486f 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -81,9 +81,11 @@ export const ru: TranslationResources = { desktop: "Напишите агенту сообщение, отметьте @files или используйте /commands и /skills.", mobile: "Сообщение,@files,/commands", fallback: "Сообщение...", + terminal: "Prompt", }, input: { accessibilityLabel: "Агент сообщений...", + terminalAccessibilityLabel: "Terminal prompt", focusHint: "{{shortcut}}, чтобы сосредоточиться", addAttachment: "Добавить вложение", interruptAgent: "Агент прерываний", @@ -1074,15 +1076,30 @@ export const ru: TranslationResources = { composerStateRequired: "Требуется состояние композитора.", selectModel: "Выберите модель", }, + tooltips: { + project: "Choose the project", + host: "Choose the host", + isolation: "Choose the isolation level", + startingRef: "Выберите, с чего начать", + launch: "Choose what to launch", + }, refPicker: { startingRef: "Начальная ссылка", - chooseStart: "Выберите, с чего начать", intoBase: "в {{baseRef}}", searching: "Идет поиск...", noMatchingRefs: "Нет подходящих ссылок.", searchPlaceholder: "Поиск филиалов и PR", title: "Начать с", }, + launch: { + title: "What to launch", + chat: "Chat", + terminal: "Terminal", + manageProfiles: "Manage profiles", + submit: "Launch", + promptPlaceholder: "Prompt {{name}}", + commandPlaceholder: "Run a command, or leave empty for a blank terminal", + }, }, desktop: { quitting: { diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 7e0a41afc..f51b61a34 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -81,9 +81,11 @@ export const zhCN: TranslationResources = { desktop: "给 Agent 发消息,标记 @files,或使用 /commands 和 /skills", mobile: "发消息,@files,/commands", fallback: "输入消息...", + terminal: "Prompt", }, input: { accessibilityLabel: "给 Agent 发消息...", + terminalAccessibilityLabel: "Terminal prompt", focusHint: "{{shortcut}} 聚焦", addAttachment: "添加附件", interruptAgent: "中断 Agent", @@ -1041,15 +1043,30 @@ export const zhCN: TranslationResources = { composerStateRequired: "Composer 状态必填", selectModel: "请选择模型", }, + tooltips: { + project: "Choose the project", + host: "Choose the host", + isolation: "Choose the isolation level", + startingRef: "选择起始位置", + launch: "Choose what to launch", + }, refPicker: { startingRef: "起始 ref", - chooseStart: "选择起始位置", intoBase: "进入 {{baseRef}}", searching: "正在搜索...", noMatchingRefs: "没有匹配的 refs。", searchPlaceholder: "搜索分支和 PR", title: "起始位置", }, + launch: { + title: "What to launch", + chat: "Chat", + terminal: "Terminal", + manageProfiles: "Manage profiles", + submit: "Launch", + promptPlaceholder: "Prompt {{name}}", + commandPlaceholder: "Run a command, or leave empty for a blank terminal", + }, }, desktop: { quitting: { diff --git a/packages/app/src/new-workspace-launch/composer-state.ts b/packages/app/src/new-workspace-launch/composer-state.ts new file mode 100644 index 000000000..61cf8e89f --- /dev/null +++ b/packages/app/src/new-workspace-launch/composer-state.ts @@ -0,0 +1,103 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { + formatResolvedCommand, + profileTakesPrompt, + substitutePrompt, +} from "@getpaseo/protocol/terminal-profiles"; +import type { TerminalProfile } from "@getpaseo/protocol/messages"; +import { resolveLaunchProfile, type LaunchTarget } from "./target"; + +export interface TerminalComposerState { + isTerminalLaunch: boolean; + /** Null for a blank terminal: no command, just the daemon's default shell. */ + selectedTerminalProfile: TerminalProfile | null; + /** False when there is nothing to type, which puts the composer in read-only. */ + terminalTakesPrompt: boolean; + /** The composer's content: the typed text, or the resolved command when read-only. */ + terminalComposerValue: string; + /** Placeholder for the editable cases. Empty when read-only, which shows a command instead. */ + terminalPlaceholder: string; + /** + * Only the read-only case needs a word: there is nothing to type, so the + * button is the whole interaction. When you can type, the icon matches chat. + */ + terminalSubmitLabel: string | undefined; + /** + * Changes whenever the target does, which is what asks the composer to take + * focus again on web. Picking from the menu moves focus into the menu, and + * you almost always want to type next, so hand it back. + */ + launchFocusKey: string; +} + +export interface ResolveTerminalComposerStateInput { + launchTarget: LaunchTarget; + terminalProfiles: readonly TerminalProfile[]; + terminalPromptText: string; + commandPlaceholder: string; + promptPlaceholder: (profileName: string) => string; + submitLabel: string; +} + +function launchKey(target: LaunchTarget): string { + return target.kind === "chat" ? "chat" : `terminal:${target.profileId}`; +} + +export function resolveTerminalComposerState( + input: ResolveTerminalComposerStateInput, +): TerminalComposerState { + const { launchTarget, terminalProfiles, terminalPromptText } = input; + const selectedTerminalProfile = resolveLaunchProfile(launchTarget, terminalProfiles); + const isTerminalLaunch = launchTarget.kind === "terminal"; + + // A blank terminal has no argv to substitute into, so what you type is a + // command for the shell rather than a prompt. Editable, always: typing a + // command at a shell prompt is the whole point of a shell. + if (!selectedTerminalProfile) { + return { + isTerminalLaunch, + selectedTerminalProfile: null, + terminalTakesPrompt: true, + terminalComposerValue: terminalPromptText, + terminalPlaceholder: input.commandPlaceholder, + terminalSubmitLabel: undefined, + launchFocusKey: launchKey(launchTarget), + }; + } + + const takesPrompt = profileTakesPrompt(selectedTerminalProfile); + return { + isTerminalLaunch, + selectedTerminalProfile, + terminalTakesPrompt: takesPrompt, + terminalComposerValue: takesPrompt + ? terminalPromptText + : formatResolvedCommand(substitutePrompt(selectedTerminalProfile, terminalPromptText)), + terminalPlaceholder: takesPrompt ? input.promptPlaceholder(selectedTerminalProfile.name) : "", + terminalSubmitLabel: takesPrompt ? undefined : input.submitLabel, + launchFocusKey: launchKey(launchTarget), + }; +} + +/** Owns the copy so the screen passes state, not strings. */ +export function useTerminalComposerState(input: { + launchTarget: LaunchTarget; + terminalProfiles: readonly TerminalProfile[]; + terminalPromptText: string; +}): TerminalComposerState { + const { t } = useTranslation(); + const { launchTarget, terminalProfiles, terminalPromptText } = input; + return useMemo( + () => + resolveTerminalComposerState({ + launchTarget, + terminalProfiles, + terminalPromptText, + commandPlaceholder: t("newWorkspace.launch.commandPlaceholder"), + promptPlaceholder: (name) => t("newWorkspace.launch.promptPlaceholder", { name }), + submitLabel: t("newWorkspace.launch.submit"), + }), + [launchTarget, terminalProfiles, terminalPromptText, t], + ); +} diff --git a/packages/app/src/new-workspace-launch/launch-control.tsx b/packages/app/src/new-workspace-launch/launch-control.tsx new file mode 100644 index 000000000..12c546edd --- /dev/null +++ b/packages/app/src/new-workspace-launch/launch-control.tsx @@ -0,0 +1,261 @@ +import { useCallback, useMemo, useState, type ReactElement } from "react"; +import { Pressable, Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { useRouter } from "expo-router"; +import { ChevronDown, MessageCircle, SquareTerminal } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { ProfileIcon } from "@/workspace-pins/launch"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { ICON_SIZE } from "@/styles/theme"; +import { + formatResolvedCommand, + getTerminalProfileIcon, + substitutePrompt, +} from "@getpaseo/protocol/terminal-profiles"; +import type { TerminalProfile } from "@getpaseo/protocol/messages"; +import { buildSettingsHostSectionRoute } from "@/utils/host-routes"; +import type { Theme } from "@/styles/theme"; +import { + BLANK_TERMINAL_PROFILE_ID, + CHAT_LAUNCH_TARGET, + isBlankTerminalTarget, + resolveLaunchProfile, + terminalLaunchTarget, + type LaunchTarget, +} from "./target"; + +const ThemedMessageCircle = withUnistyles(MessageCircle); +const ThemedSquareTerminal = withUnistyles(SquareTerminal); +const ThemedChevronDown = withUnistyles(ChevronDown); + +const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const extraMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted }); + +const chatIcon = ; +const blankTerminalIcon = ; + +/** Owns its own leading icon and select callback so neither is rebuilt per render of the menu. */ +function LaunchProfileMenuItem({ + profile, + selected, + onSelect, +}: { + profile: TerminalProfile; + selected: boolean; + onSelect: (profileId: string) => void; +}): ReactElement { + const handleSelect = useCallback(() => onSelect(profile.id), [onSelect, profile.id]); + const leading = useMemo( + () => , + [profile], + ); + // What the profile runs before any prompt is typed, so the row says what it + // actually launches rather than making you open settings to find out. Nested + // in the label rather than passed as `description` so it sits inline right + // after the name and truncates with it on one line. + const commandPreview = useMemo( + () => formatResolvedCommand(substitutePrompt(profile, "")), + [profile], + ); + return ( + + {profile.name} {commandPreview} + + ); +} + +export interface LaunchControlProps { + serverId: string; + target: LaunchTarget; + onChange: (target: LaunchTarget) => void; + profiles: readonly TerminalProfile[]; + disabled?: boolean; + /** + * The meta row's shared badge style. Passed in rather than redeclared so this + * trigger stays pixel-identical to the project, host, and branch triggers it + * sits beside; a local copy drifts the moment one of them changes. + */ + badgePressableStyle: React.ComponentProps["style"]; +} + +function TriggerIcon({ + target, + profile, +}: { + target: LaunchTarget; + profile: TerminalProfile | null; +}): ReactElement { + if (target.kind === "chat") { + return ; + } + if (profile) { + return ; + } + return ; +} + +/** + * The meta-row control choosing what New workspace submits to: the chat agent, + * or a terminal. One trigger shaped like every other chip on that row, opening + * a menu whose Chat and Terminal groups are what keep a terminal profile named + * "Codex" from reading as a chat Codex agent. + */ +export function LaunchControl({ + serverId, + target, + onChange, + profiles, + disabled = false, + badgePressableStyle, +}: LaunchControlProps) { + const { t } = useTranslation(); + const router = useRouter(); + const [open, setOpen] = useState(false); + + const selectedProfile = useMemo(() => resolveLaunchProfile(target, profiles), [target, profiles]); + + // A profile's own name and logo say nothing about where it runs: a row + // reading "(openai logo) Codex" is indistinguishable from a chat Codex agent. + // Leading with the noun fixes that, and de-emphasising the profile name keeps + // "Terminal" the thing you read first. + const triggerLabel = + target.kind === "chat" ? t("newWorkspace.launch.chat") : t("newWorkspace.launch.terminal"); + + const selectChat = useCallback(() => onChange(CHAT_LAUNCH_TARGET), [onChange]); + const selectBlankTerminal = useCallback( + () => onChange(terminalLaunchTarget(BLANK_TERMINAL_PROFILE_ID)), + [onChange], + ); + const selectTerminalProfile = useCallback( + (profileId: string) => onChange(terminalLaunchTarget(profileId)), + [onChange], + ); + const openProfileSettings = useCallback(() => { + router.push(buildSettingsHostSectionRoute(serverId, "terminals")); + }, [router, serverId]); + + return ( + + {/* The trigger is the Pressable itself, styled by the meta row's shared + badge style, so it lands pixel-identical to the project, host, and + branch chips it sits beside. */} + {/* MenuTrigger is not a forwardRef component and pins its own ref, so the + tooltip anchors to a wrapping View instead. Suppressed while the menu + is open so the two surfaces never overlap. */} + + + + + + + + + {triggerLabel} + {selectedProfile ? ( + {selectedProfile.name} + ) : null} + + + + + + + {t("newWorkspace.tooltips.launch")} + + + + {/* Chat leads with no label of its own: a group label over a single row + is noise, and the sheet title already says what the menu is on + compact. Terminal keeps its label because that grouping is what + stops a profile named Codex reading as a chat Codex agent. */} + + {t("newWorkspace.launch.chat")} + + {t("newWorkspace.launch.terminal")} + + {t("newWorkspace.launch.terminal")} + + {profiles.map((profile) => ( + + ))} + + + {t("newWorkspace.launch.manageProfiles")} + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + iconBox: { + width: theme.iconSize.md, + height: theme.iconSize.md, + alignItems: "center", + justifyContent: "center", + flexShrink: 0, + }, + label: { + minWidth: 0, + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundMuted, + flexShrink: 1, + }, + tooltipText: { + fontSize: theme.fontSize.sm, + color: theme.colors.popoverForeground, + }, + profileName: { + fontSize: theme.fontSize.sm, + color: theme.colors.foregroundExtraMuted, + }, + command: { + fontFamily: theme.fontFamily.mono, + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, +})); diff --git a/packages/app/src/new-workspace-launch/target.test.ts b/packages/app/src/new-workspace-launch/target.test.ts new file mode 100644 index 000000000..9fc024d17 --- /dev/null +++ b/packages/app/src/new-workspace-launch/target.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import type { TerminalProfile } from "@getpaseo/protocol/messages"; +import { + BLANK_TERMINAL_PROFILE_ID, + CHAT_LAUNCH_TARGET, + isBlankTerminalTarget, + resolveLaunchProfile, + resolveLaunchTarget, + terminalLaunchTarget, +} from "./target"; + +const claude: TerminalProfile = { + id: "claude", + name: "Claude Code", + command: "claude", + args: ["{{{prompt}}}"], +}; +const codex: TerminalProfile = { + id: "codex", + name: "Codex", + command: "codex", + args: ["{{{prompt}}}"], +}; +const profiles = [claude, codex]; + +describe("resolveLaunchTarget", () => { + it("defaults to chat when nothing is persisted", () => { + expect(resolveLaunchTarget(undefined, profiles)).toEqual(CHAT_LAUNCH_TARGET); + }); + + it("keeps chat as chat", () => { + expect(resolveLaunchTarget(CHAT_LAUNCH_TARGET, profiles)).toEqual(CHAT_LAUNCH_TARGET); + }); + + it("keeps a terminal target whose profile still resolves", () => { + const target = terminalLaunchTarget("codex"); + expect(resolveLaunchTarget(target, profiles)).toEqual(target); + }); + + it("keeps the blank terminal, which is never resolved against profiles", () => { + const target = terminalLaunchTarget(BLANK_TERMINAL_PROFILE_ID); + expect(resolveLaunchTarget(target, [])).toEqual(target); + }); + + it("falls back to chat when the persisted profile no longer exists", () => { + expect(resolveLaunchTarget(terminalLaunchTarget("removed-profile"), profiles)).toEqual( + CHAT_LAUNCH_TARGET, + ); + }); + + it("falls back to chat when every profile has been removed", () => { + expect(resolveLaunchTarget(terminalLaunchTarget("codex"), [])).toEqual(CHAT_LAUNCH_TARGET); + }); +}); + +describe("resolveLaunchProfile", () => { + it("returns null for chat", () => { + expect(resolveLaunchProfile(CHAT_LAUNCH_TARGET, profiles)).toBeNull(); + }); + + it("returns null for the blank terminal, which runs no command", () => { + expect( + resolveLaunchProfile(terminalLaunchTarget(BLANK_TERMINAL_PROFILE_ID), profiles), + ).toBeNull(); + }); + + it("returns the matching profile", () => { + expect(resolveLaunchProfile(terminalLaunchTarget("claude"), profiles)).toBe(claude); + }); + + it("returns null when the profile id matches nothing", () => { + expect(resolveLaunchProfile(terminalLaunchTarget("missing"), profiles)).toBeNull(); + }); +}); + +describe("isBlankTerminalTarget", () => { + it("is true only for a terminal target carrying the blank id", () => { + expect(isBlankTerminalTarget(terminalLaunchTarget(BLANK_TERMINAL_PROFILE_ID))).toBe(true); + expect(isBlankTerminalTarget(terminalLaunchTarget("claude"))).toBe(false); + expect(isBlankTerminalTarget(CHAT_LAUNCH_TARGET)).toBe(false); + }); +}); diff --git a/packages/app/src/new-workspace-launch/target.ts b/packages/app/src/new-workspace-launch/target.ts new file mode 100644 index 000000000..9664ba6e9 --- /dev/null +++ b/packages/app/src/new-workspace-launch/target.ts @@ -0,0 +1,49 @@ +import type { TerminalProfile } from "@getpaseo/protocol/messages"; +import type { LaunchTarget } from "@/create-agent-preferences/preferences"; + +export type { LaunchTarget }; + +// A blank terminal isn't a configured TerminalProfile, it's "no command", i.e. +// the daemon's default shell. It still needs a stable id to round-trip through +// FormPreferences (`launchTarget.profileId` is a required string) and to sit +// alongside real profiles in the picker. +export const BLANK_TERMINAL_PROFILE_ID = "blank"; + +export const CHAT_LAUNCH_TARGET: LaunchTarget = { kind: "chat" }; + +export function isBlankTerminalTarget(target: LaunchTarget): boolean { + return target.kind === "terminal" && target.profileId === BLANK_TERMINAL_PROFILE_ID; +} + +export function terminalLaunchTarget(profileId: string): LaunchTarget { + return { kind: "terminal", profileId }; +} + +/** The selected profile for a terminal target, or null for Chat and blank terminals. */ +export function resolveLaunchProfile( + target: LaunchTarget, + profiles: readonly TerminalProfile[], +): TerminalProfile | null { + if (target.kind !== "terminal" || isBlankTerminalTarget(target)) { + return null; + } + return profiles.find((profile) => profile.id === target.profileId) ?? null; +} + +/** + * Resolves a target against the currently available profiles, falling back to + * Chat when it names a profile that no longer exists (removed since it was + * picked or persisted). + */ +export function resolveLaunchTarget( + target: LaunchTarget | undefined, + profiles: readonly TerminalProfile[], +): LaunchTarget { + if (!target || target.kind === "chat") { + return CHAT_LAUNCH_TARGET; + } + if (isBlankTerminalTarget(target)) { + return target; + } + return resolveLaunchProfile(target, profiles) ? target : CHAT_LAUNCH_TARGET; +} diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 8271ddb92..459f04b50 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -33,6 +33,13 @@ import { useAgentInputDraft } from "@/composer/draft/input-draft"; import { useForgeSearchQuery } from "@/git/use-forge-search-query"; import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { ensureCheckoutStatus } from "@/git/checkout-status-cache"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { resolveTerminalProfiles } from "@getpaseo/protocol/terminal-profiles"; +import type { TerminalProfile } from "@getpaseo/protocol/messages"; +import { LaunchControl } from "@/new-workspace-launch/launch-control"; +import { resolveLaunchTarget, type LaunchTarget } from "@/new-workspace-launch/target"; +import { useTerminalComposerState } from "@/new-workspace-launch/composer-state"; +import { runCreateTerminalWorkspace } from "./new-workspace-terminal"; import { useHostRuntimeClient, useHostRuntimeConnectionStatuses, @@ -79,6 +86,7 @@ import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { ComposerAttachment } from "@/attachments/types"; import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import type { MessagePayload } from "@/composer/types"; +import type { UserComposerAttachment } from "@/attachments/types"; import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { CreatePaseoWorktreeInput } from "@getpaseo/client/internal/daemon-client"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; @@ -141,7 +149,7 @@ function resolveVisibleDraftContextScopeKeys(input: { } function isNewWorkspacePending(input: { - pendingAction: "chat" | "empty" | null; + pendingAction: "chat" | "empty" | "terminal" | null; isDraftHandoffActive: boolean; }): boolean { return input.pendingAction !== null || input.isDraftHandoffActive; @@ -170,7 +178,29 @@ interface NewWorkspaceScreenProps { draftId?: string; } +// A terminal launch sends argv, not a message: there is nothing to attach and +// no draft to persist, so the composer's attachment and draft seams are inert. +const NO_TERMINAL_ATTACHMENTS: UserComposerAttachment[] = []; +function noopChangeAttachments() {} +function noopClearDraft() {} + const PROJECT_ICON_FALLBACK_FONT_SIZE = 10; +const ThemedChevronDown = withUnistyles(ChevronDown); +const chevronExtraMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted }); + +// Every picker chip on this screen shares one chevron so they stay a single +// visual family. Extra-muted: the chevron is an affordance, not information, +// and it should sit behind the label it belongs to. +function MetaChevron(): ReactElement { + return ( + + + + ); +} + +const metaChevron = ; + // Stable reference so the keyboard-action handler doesn't re-register each render. const PROJECT_PICK_ACTIONS: readonly KeyboardActionId[] = ["workspace.project.pick"]; // Height of a single picker-trigger badge. The Base-row spacer reserves exactly @@ -231,6 +261,7 @@ function RefPickerTrigger({ ["style"]; label: string; + tooltipLabel: string; projectViewKey: string | null; iconDataUri: string | null; iconColor: string; @@ -281,6 +314,7 @@ function ProjectPickerTrigger({ - Choose project + {tooltipLabel} ); @@ -592,6 +626,7 @@ function IsolationPickerTrigger({ badgePressableStyle, isolation, label, + tooltipLabel, iconColor, iconSize, }: { @@ -601,30 +636,39 @@ function IsolationPickerTrigger({ badgePressableStyle: React.ComponentProps["style"]; isolation: "local" | "worktree"; label: string; + tooltipLabel: string; iconColor: string; iconSize: number; }) { return ( - - - {isolation === "worktree" ? ( - - ) : ( - - )} - - - {label} - - + + + + + {isolation === "worktree" ? ( + + ) : ( + + )} + + + {label} + + + + + {tooltipLabel} + + ); } @@ -1273,12 +1317,19 @@ interface NewWorkspaceFormStackInput { renderOption: RefPickerRenderOption; showRefPicker: boolean; }; + launch: { + serverId: string; + target: LaunchTarget; + onChange: (target: LaunchTarget) => void; + profiles: readonly TerminalProfile[]; + disabled: boolean; + }; } function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactElement { const { theme } = useUnistyles(); const { t } = useTranslation(); - const { isCompact, isPending, project, host, isolation, base } = input; + const { isCompact, isPending, project, host, isolation, base, launch } = input; const selectedHostLabel = host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host"; @@ -1307,6 +1358,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme disabled={isPending} badgePressableStyle={badgePressableStyle} label={project.triggerLabel} + tooltipLabel={t("newWorkspace.tooltips.project")} projectViewKey={project.selectedProject?.viewKey ?? null} iconDataUri={ project.selectedProject @@ -1350,23 +1402,30 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme desktopMinWidth={200} hostOptionTestID={newWorkspaceHostOptionTestID} > - - - - - - {selectedHostLabel} - - - + + + + + + + + {selectedHostLabel} + + {metaChevron} + + + + {t("newWorkspace.tooltips.host")} + + ) : null; @@ -1380,6 +1439,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme badgePressableStyle={badgePressableStyle} isolation={isolation.effectiveIsolation} label={isolationTriggerLabel} + tooltipLabel={t("newWorkspace.tooltips.isolation")} iconColor={theme.colors.foregroundMuted} iconSize={theme.iconSize.sm} /> @@ -1407,7 +1467,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme selectedItem={base.selectedItem} triggerLabel={base.triggerLabel} accessibilityLabel={t("newWorkspace.refPicker.startingRef")} - tooltipLabel={t("newWorkspace.refPicker.chooseStart")} + tooltipLabel={t("newWorkspace.tooltips.startingRef")} iconColor={theme.colors.foregroundMuted} iconSize={theme.iconSize.sm} /> @@ -1429,6 +1489,17 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme ) : null; + const launchControl = ( + + ); + return isCompact ? ( {projectControl} @@ -1440,6 +1511,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme )} {baseControl ? {baseControl} : } + {launchControl} ) : ( @@ -1447,6 +1519,8 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme {hostControl} {isolationControl} {baseControl} + + {launchControl} ); } @@ -1488,7 +1562,7 @@ export function NewWorkspaceScreen({ const [createdWorkspace, setCreatedWorkspace] = useState | null>(null); - const [pendingAction, setPendingAction] = useState<"chat" | "empty" | null>(null); + const [pendingAction, setPendingAction] = useState<"chat" | "empty" | "terminal" | null>(null); const [pickerOpen, setPickerOpen] = useState(false); const [projectPickerOpen, setProjectPickerOpen] = useState(false); const openAddProjectPicker = useOpenAddProject(); @@ -1501,6 +1575,38 @@ export function NewWorkspaceScreen({ const hostPickerAnchorRef = useRef(null); const isDraftHandoffActive = useIsNewWorkspaceDraftHandoffActive({ draftId, selectedServerId }); + // Launch target: what the composer submits to (chat agent, or a terminal + // profile). Mirrors useWorkspaceIsolation's pattern below: the derived + // value reads live from preferences until the user manually picks + // something in this screen, so the async preferences load doesn't race a + // frozen useState initializer. + const { preferences: formPreferences, updatePreferences: updateFormPreferences } = + useFormPreferences(); + const { config: daemonConfig } = useDaemonConfig(selectedServerId); + const terminalProfiles: readonly TerminalProfile[] = useMemo( + () => resolveTerminalProfiles(daemonConfig?.terminalProfiles), + [daemonConfig?.terminalProfiles], + ); + // Manual selection wins once the user picks something; until then the target + // reads live from preferences so the async load can't race a frozen + // initializer. Both go through `resolveLaunchTarget`, so a profile deleted + // daemon-side falls back to chat rather than leaving a dead selection. + const [manualLaunchTarget, setManualLaunchTarget] = useState(null); + const launchTarget = useMemo( + () => resolveLaunchTarget(manualLaunchTarget ?? formPreferences.launchTarget, terminalProfiles), + [manualLaunchTarget, formPreferences.launchTarget, terminalProfiles], + ); + const [terminalPromptText, setTerminalPromptText] = useState(""); + const { + isTerminalLaunch, + selectedTerminalProfile, + terminalTakesPrompt, + terminalComposerValue, + terminalPlaceholder, + terminalSubmitLabel, + launchFocusKey, + } = useTerminalComposerState({ launchTarget, terminalProfiles, terminalPromptText }); + useEffect(() => { const trimmed = pickerSearchQuery.trim(); const timer = setTimeout(() => setDebouncedPickerSearchQuery(trimmed), 180); @@ -1930,6 +2036,7 @@ export function NewWorkspaceScreen({ try { setErrorMessage(null); await composerState?.persistFormPreferences(); + await updateFormPreferences({ launchTarget }); if (isEmptyWorkspaceSubmission(payload)) { setPendingAction("empty"); await runCreateEmptyWorkspace({ @@ -1970,13 +2077,65 @@ export function NewWorkspaceScreen({ draftKey, ensureWorkspace, forkDraftSetup, + launchTarget, selectedServerId, supportsForgeSearch, t, toast, + updateFormPreferences, ], ); + const handleSubmitTerminalLaunch = useCallback(async () => { + try { + setErrorMessage(null); + await updateFormPreferences({ launchTarget }); + setPendingAction("terminal"); + await runCreateTerminalWorkspace({ + cwd: selectedSourceDirectory ?? "", + prompt: terminalPromptText, + profile: selectedTerminalProfile, + profileName: selectedTerminalProfile?.name, + ensureWorkspace, + createTerminal: async (input) => { + const connectedClient = withConnectedClient(); + const createdTerminal = await connectedClient.createTerminal( + input.workspaceDirectory, + input.name, + undefined, + { command: input.command, args: input.args, workspaceId: input.workspaceId }, + ); + if (!createdTerminal.terminal) { + throw new Error(createdTerminal.error ?? t("newWorkspace.errors.createWorktreeFailed")); + } + return { terminalId: createdTerminal.terminal.id }; + }, + sendTerminalInput: (terminalId, data) => { + withConnectedClient().sendTerminalInput(terminalId, { type: "input", data }); + }, + serverId: selectedServerId, + navigate: (targetServerId, workspaceId, target) => + navigateToWorkspace({ serverId: targetServerId, workspaceId, target }), + }); + } catch (error) { + const message = toErrorMessage(error); + setPendingAction(null); + setErrorMessage(message); + toast.error(message); + } + }, [ + ensureWorkspace, + launchTarget, + selectedServerId, + selectedSourceDirectory, + selectedTerminalProfile, + t, + terminalPromptText, + toast, + updateFormPreferences, + withConnectedClient, + ]); + const renderPickerOption = useCallback( (props: { option: ComboboxOptionType; @@ -2095,6 +2254,13 @@ export function NewWorkspaceScreen({ renderOption: renderPickerOption, showRefPicker, }, + launch: { + serverId: selectedServerId, + target: launchTarget, + onChange: setManualLaunchTarget, + profiles: terminalProfiles, + disabled: isPending, + }, }); const screenHeaderLeft = useMemo(() => , []); @@ -2109,33 +2275,62 @@ export function NewWorkspaceScreen({ {t("newWorkspace.title")} {formStack} - + {isTerminalLaunch ? ( + + ) : ( + + )} {errorMessage ? {errorMessage} : null} @@ -2192,8 +2387,11 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", marginBottom: theme.spacing[8], // The badge adds its own left padding; offset it so the project icon's left - // edge lands exactly on the "New workspace" title's left edge. + // edge lands exactly on the "New workspace" title's left edge. The trailing + // inset mirrors it so the launch chip stops on the composer's inner content + // rather than running out to the composer's border. paddingLeft: theme.spacing[4], + paddingRight: theme.spacing[4], gap: theme.spacing[2], }, // The row's left inset matches the heading's text x (composerTitleContainer @@ -2208,6 +2406,12 @@ const styles = StyleSheet.create((theme) => ({ baseSpacer: { height: BADGE_HEIGHT, }, + // Pushes the launch control to the trailing edge of the desktop meta row, + // next to project/host/branch. The row's own right inset (formStackDesktop) + // lands it on the composer's inner content, matching the left chips. + launchSpacer: { + flex: 1, + }, badge: { flexDirection: "row", alignItems: "center", @@ -2242,6 +2446,10 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontVariant: ["tabular-nums"], }, + chevronContainer: { + flexShrink: 0, + transform: [{ translateY: 1 }], + }, badgeIconBox: { width: theme.iconSize.md, height: theme.iconSize.md, diff --git a/packages/app/src/screens/new-workspace-terminal.test.ts b/packages/app/src/screens/new-workspace-terminal.test.ts new file mode 100644 index 000000000..0e54ac935 --- /dev/null +++ b/packages/app/src/screens/new-workspace-terminal.test.ts @@ -0,0 +1,247 @@ +import { describe, expect, it, vi } from "vitest"; +import { runCreateTerminalWorkspace } from "./new-workspace-terminal"; + +function createRecordingNavigate() { + const recorded: Array<{ serverId: string; workspaceId: string; target: unknown }> = []; + return { + recorded, + navigate: (serverId: string, workspaceId: string, target: unknown) => { + recorded.push({ serverId, workspaceId, target }); + }, + }; +} + +describe("runCreateTerminalWorkspace", () => { + it("creates the workspace without a chat agent, substitutes the prompt into the profile, and navigates to the terminal tab", async () => { + const workspace = { id: "workspace-123", workspaceDirectory: "/repo/workspace-123" }; + const ensureWorkspace = vi.fn().mockResolvedValue(workspace); + const createTerminal = vi.fn().mockResolvedValue({ terminalId: "term-1" }); + const sendTerminalInput = vi.fn(); + const { navigate, recorded } = createRecordingNavigate(); + + await runCreateTerminalWorkspace({ + cwd: "/repo", + prompt: "fix the bug", + profile: { command: "claude", args: ["{{{prompt}}}"] }, + profileName: "Claude Code", + ensureWorkspace, + createTerminal, + sendTerminalInput, + serverId: "server-abc", + navigate, + }); + + // Seeds the generated title and branch name without creating a chat agent. + expect(ensureWorkspace).toHaveBeenCalledWith({ + cwd: "/repo", + prompt: "fix the bug", + attachments: [], + withInitialAgent: false, + }); + expect(createTerminal).toHaveBeenCalledWith({ + workspaceDirectory: "/repo/workspace-123", + workspaceId: "workspace-123", + name: "Claude Code", + command: "claude", + args: ["fix the bug"], + }); + expect(recorded).toEqual([ + { + serverId: "server-abc", + workspaceId: "workspace-123", + target: { kind: "terminal", terminalId: "term-1" }, + }, + ]); + }); + + it("drops a sentinel-only arg when the prompt is empty, so a bare command still launches", async () => { + const workspace = { id: "ws-1", workspaceDirectory: "/repo/ws-1" }; + const ensureWorkspace = vi.fn().mockResolvedValue(workspace); + const createTerminal = vi.fn().mockResolvedValue({ terminalId: "term-2" }); + const sendTerminalInput = vi.fn(); + const { navigate } = createRecordingNavigate(); + + await runCreateTerminalWorkspace({ + cwd: "/repo", + prompt: "", + profile: { command: "claude", args: ["{{{prompt}}}"] }, + profileName: "Claude Code", + ensureWorkspace, + createTerminal, + sendTerminalInput, + serverId: "server-abc", + navigate, + }); + + expect(createTerminal).toHaveBeenCalledWith({ + workspaceDirectory: "/repo/ws-1", + workspaceId: "ws-1", + name: "Claude Code", + command: "claude", + args: [], + }); + }); + + it("does not seed workspace naming from a hidden draft for a launch-only profile", async () => { + const workspace = { id: "ws-1", workspaceDirectory: "/repo/ws-1" }; + const ensureWorkspace = vi.fn().mockResolvedValue(workspace); + const createTerminal = vi.fn().mockResolvedValue({ terminalId: "term-2" }); + const sendTerminalInput = vi.fn(); + const { navigate } = createRecordingNavigate(); + + await runCreateTerminalWorkspace({ + cwd: "/repo", + prompt: "hidden draft from the previous profile", + profile: { command: "npm", args: ["run", "dev"] }, + profileName: "Dev server", + ensureWorkspace, + createTerminal, + sendTerminalInput, + serverId: "server-abc", + navigate, + }); + + expect(ensureWorkspace).toHaveBeenCalledWith({ + cwd: "/repo", + prompt: "", + attachments: [], + withInitialAgent: false, + }); + expect(createTerminal).toHaveBeenCalledWith({ + workspaceDirectory: "/repo/ws-1", + workspaceId: "ws-1", + name: "Dev server", + command: "npm", + args: ["run", "dev"], + }); + }); + + it("does not type into the terminal when a profile took the prompt as argv", async () => { + const ensureWorkspace = vi + .fn() + .mockResolvedValue({ id: "ws-9", workspaceDirectory: "/repo/ws-9" }); + const createTerminal = vi.fn().mockResolvedValue({ terminalId: "term-9" }); + const sendTerminalInput = vi.fn(); + const { navigate } = createRecordingNavigate(); + + await runCreateTerminalWorkspace({ + cwd: "/repo", + prompt: "fix the bug", + profile: { command: "claude", args: ["{{{prompt}}}"] }, + profileName: "Claude Code", + ensureWorkspace, + createTerminal, + sendTerminalInput, + serverId: "server-abc", + navigate, + }); + + expect(sendTerminalInput).not.toHaveBeenCalled(); + }); + + it("seeds workspace naming from a blank terminal's command too", async () => { + const ensureWorkspace = vi + .fn() + .mockResolvedValue({ id: "ws-7", workspaceDirectory: "/repo/ws-7" }); + const createTerminal = vi.fn().mockResolvedValue({ terminalId: "term-7" }); + const sendTerminalInput = vi.fn(); + const { navigate } = createRecordingNavigate(); + + await runCreateTerminalWorkspace({ + cwd: "/repo", + prompt: "npm run dev", + profile: null, + profileName: undefined, + ensureWorkspace, + createTerminal, + sendTerminalInput, + serverId: "server-abc", + navigate, + }); + + expect(ensureWorkspace).toHaveBeenCalledWith({ + cwd: "/repo", + prompt: "npm run dev", + attachments: [], + withInitialAgent: false, + }); + }); + + it("types the command into the shell, with a carriage return, for a blank terminal", async () => { + const ensureWorkspace = vi + .fn() + .mockResolvedValue({ id: "ws-5", workspaceDirectory: "/repo/ws-5" }); + const createTerminal = vi.fn().mockResolvedValue({ terminalId: "term-5" }); + const sendTerminalInput = vi.fn(); + const { navigate } = createRecordingNavigate(); + + await runCreateTerminalWorkspace({ + cwd: "/repo", + prompt: " npm run dev ", + profile: null, + profileName: undefined, + ensureWorkspace, + createTerminal, + sendTerminalInput, + serverId: "server-abc", + navigate, + }); + + // The shell parses it, so it is typed rather than turned into argv. + expect(createTerminal).toHaveBeenCalledWith({ + workspaceDirectory: "/repo/ws-5", + workspaceId: "ws-5", + name: undefined, + }); + expect(sendTerminalInput).toHaveBeenCalledWith("term-5", "npm run dev\r"); + }); + + it("launches a blank terminal without typing anything when no command was entered", async () => { + const ensureWorkspace = vi + .fn() + .mockResolvedValue({ id: "ws-6", workspaceDirectory: "/repo/ws-6" }); + const createTerminal = vi.fn().mockResolvedValue({ terminalId: "term-6" }); + const sendTerminalInput = vi.fn(); + const { navigate } = createRecordingNavigate(); + + await runCreateTerminalWorkspace({ + cwd: "/repo", + prompt: " ", + profile: null, + profileName: undefined, + ensureWorkspace, + createTerminal, + sendTerminalInput, + serverId: "server-abc", + navigate, + }); + + expect(sendTerminalInput).not.toHaveBeenCalled(); + }); + + it("launches the default shell with no command/args and no name when profile is null", async () => { + const workspace = { id: "ws-1", workspaceDirectory: "/repo/ws-1" }; + const ensureWorkspace = vi.fn().mockResolvedValue(workspace); + const createTerminal = vi.fn().mockResolvedValue({ terminalId: "term-3" }); + const sendTerminalInput = vi.fn(); + const { navigate } = createRecordingNavigate(); + + await runCreateTerminalWorkspace({ + cwd: "/repo", + prompt: "ls -la", + profile: null, + profileName: undefined, + ensureWorkspace, + createTerminal, + sendTerminalInput, + serverId: "server-abc", + navigate, + }); + + expect(createTerminal).toHaveBeenCalledWith({ + workspaceDirectory: "/repo/ws-1", + workspaceId: "ws-1", + name: undefined, + }); + }); +}); diff --git a/packages/app/src/screens/new-workspace-terminal.ts b/packages/app/src/screens/new-workspace-terminal.ts new file mode 100644 index 000000000..1cd8707f3 --- /dev/null +++ b/packages/app/src/screens/new-workspace-terminal.ts @@ -0,0 +1,88 @@ +import type { normalizeWorkspaceDescriptor } from "@/stores/session-store"; +import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import type { WorkspaceTabTarget } from "@/workspace-tabs/model"; +import { + profileTakesPrompt, + substitutePrompt, + type SubstitutableCommand, +} from "@getpaseo/protocol/terminal-profiles"; + +export interface CreatedTerminal { + terminalId: string; +} + +export interface CreateTerminalWorkspaceInput { + cwd: string; + prompt: string; + /** The selected profile's command/args, or null for the default shell. */ + profile: SubstitutableCommand | null; + /** Terminal tab name: the profile's display name, or undefined for shell. */ + profileName: string | undefined; + ensureWorkspace: (input: { + cwd: string; + prompt: string; + attachments: AgentAttachment[]; + withInitialAgent: boolean; + }) => Promise>; + createTerminal: (input: { + workspaceDirectory: string; + workspaceId: string; + name?: string; + command?: string; + args?: string[]; + }) => Promise; + /** Types text into a live terminal. Used for the shell path, where the typed command is a command, not argv. */ + sendTerminalInput: (terminalId: string, data: string) => void; + serverId: string; + navigate: (serverId: string, workspaceId: string, target: WorkspaceTabTarget) => void; +} + +export async function runCreateTerminalWorkspace( + input: CreateTerminalWorkspaceInput, +): Promise { + const { + cwd, + prompt, + profile, + profileName, + ensureWorkspace, + createTerminal, + sendTerminalInput, + serverId, + navigate, + } = input; + // Seeds the generated workspace title and branch name. `withInitialAgent` + // stays false, so this hands the daemon something to name the workspace after + // without creating a chat agent. Worth passing even for a blank terminal, + // where the text is a command: a branch called `npm-run-dev` beats a random + // slug. + const namingPrompt = profile && !profileTakesPrompt(profile) ? "" : prompt; + const ensuredWorkspace = await ensureWorkspace({ + cwd, + prompt: namingPrompt, + attachments: [], + withInitialAgent: false, + }); + const resolved = profile ? substitutePrompt(profile, prompt) : null; + const createdTerminal = await createTerminal({ + workspaceDirectory: ensuredWorkspace.workspaceDirectory, + workspaceId: ensuredWorkspace.id, + name: profileName, + ...(resolved ? { command: resolved.command, args: resolved.args } : {}), + }); + + // A profile's prompt is argv, substituted into command/args above. A blank + // terminal has no argv to substitute into, so the typed text is a command and + // has to reach the shell the way any command does: typed at its prompt. That + // also means the user's own shell parses it, which is the only thing that + // gets quoting, pipes, and globs right on every platform. + const trimmedPrompt = prompt.trim(); + if (!profile && trimmedPrompt) { + sendTerminalInput(createdTerminal.terminalId, `${trimmedPrompt}\r`); + } + + navigate(serverId, ensuredWorkspace.id, { + kind: "terminal", + terminalId: createdTerminal.terminalId, + }); +} diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 9b7729126..9a2a41334 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -19,7 +19,7 @@ import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles" import type { TerminalProfile } from "@getpaseo/protocol/messages"; import { getTerminalProfileIcon, - resolveTerminalProfiles, + DEFAULT_TERMINAL_PROFILES, } from "@getpaseo/protocol/terminal-profiles"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { SettingsTextAreaCard } from "@/components/settings-textarea"; @@ -1547,8 +1547,11 @@ function TerminalProfilesSection({ serverId }: { serverId: string }) { } | null>(null); const [isAdding, setIsAdding] = useState(false); + // Settings edits what is persisted, not the adopted view. Any save here + // writes the whole list back, so resolving first would bake read-time prompt + // adoption into the user's config the first time they reorder a row. const profiles = useMemo( - () => (config ? resolveTerminalProfiles(config.terminalProfiles) : null), + () => (config ? (config.terminalProfiles ?? DEFAULT_TERMINAL_PROFILES) : null), [config], ); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 14da7bd67..3ee7f59b3 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -193,6 +193,7 @@ import { } from "@/screens/workspace/terminals/use-workspace-terminals"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { + resolveTerminalProfileLaunch, getTerminalProfileIcon, resolveTerminalProfiles, } from "@getpaseo/protocol/terminal-profiles"; @@ -1019,11 +1020,7 @@ function HeaderMenuProfileItem({ onCreateTerminalWithProfile, }: HeaderMenuProfileItemProps) { const handleSelect = useCallback(() => { - onCreateTerminalWithProfile({ - name: profile.name, - command: profile.command, - args: profile.args, - }); + onCreateTerminalWithProfile(resolveTerminalProfileLaunch(profile, "")); }, [onCreateTerminalWithProfile, profile]); const icon = getTerminalProfileIcon(profile); diff --git a/packages/app/src/workspace-pins/run.test.ts b/packages/app/src/workspace-pins/run.test.ts index 1e1d2a0f0..1c6f9730f 100644 --- a/packages/app/src/workspace-pins/run.test.ts +++ b/packages/app/src/workspace-pins/run.test.ts @@ -46,7 +46,21 @@ describe("runPinnedTabTarget", () => { const { launches, handlers } = recordingHandlers(); runPinnedTabTarget({ kind: "profile", profileId: "claude" }, PROFILES, handlers); expect(launches).toEqual([ - { action: "profile", profile: { name: "Claude Code", command: "claude" } }, + { action: "profile", profile: { name: "Claude Code", command: "claude", args: [] } }, + ]); + }); + + it("launches a prompt-taking profile bare, never with the sentinel as a literal argument", () => { + const { launches, handlers } = recordingHandlers(); + runPinnedTabTarget( + { kind: "profile", profileId: "codex" }, + [{ id: "codex", name: "Codex", command: "codex", args: ["--yolo", "{{{prompt}}}"] }], + handlers, + ); + // A pin has nowhere to type, so the prompt-only arg drops instead of + // reaching the CLI as the literal text `{{{prompt}}}`. + expect(launches).toEqual([ + { action: "profile", profile: { name: "Codex", command: "codex", args: ["--yolo"] } }, ]); }); diff --git a/packages/app/src/workspace-pins/run.ts b/packages/app/src/workspace-pins/run.ts index 553149c90..7d36a4913 100644 --- a/packages/app/src/workspace-pins/run.ts +++ b/packages/app/src/workspace-pins/run.ts @@ -1,3 +1,4 @@ +import { resolveTerminalProfileLaunch } from "@getpaseo/protocol/terminal-profiles"; import type { TerminalProfile } from "@getpaseo/protocol/messages"; import type { TerminalProfileInput } from "@/screens/workspace/terminals/use-workspace-terminals"; import type { PinnedTabTarget } from "@/workspace-pins/target"; @@ -30,9 +31,7 @@ export function runPinnedTabTarget( if (!profile) { return; } - handlers.createTerminalWithProfile({ - name: profile.name, - command: profile.command, - args: profile.args, - }); + // Nothing to type from a pin, so the prompt is empty and any sentinel arg + // drops out. + handlers.createTerminalWithProfile(resolveTerminalProfileLaunch(profile, "")); } diff --git a/packages/protocol/src/terminal-profiles.test.ts b/packages/protocol/src/terminal-profiles.test.ts index 19b2f520c..6bc3d37ac 100644 --- a/packages/protocol/src/terminal-profiles.test.ts +++ b/packages/protocol/src/terminal-profiles.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_TERMINAL_PROFILES, + PROMPT_SENTINEL, + formatResolvedCommand, getTerminalProfileIcon, guessTerminalProfileIcon, + profileTakesPrompt, resolveTerminalProfiles, + substitutePrompt, } from "./terminal-profiles.js"; describe("resolveTerminalProfiles", () => { @@ -17,10 +21,81 @@ describe("resolveTerminalProfiles", () => { expect(result).toEqual([]); }); - it("returns a custom array as-is", () => { - const custom = [{ id: "zsh", name: "Zsh", command: "zsh" }]; - const result = resolveTerminalProfiles(custom); - expect(result).toBe(custom); + // A profile that predates the sentinel got materialized into user config the + // first time anyone touched the profile list, so it would otherwise be stuck + // launch-only while a fresh install gets prompt support. + it("appends the sentinel to a bare shipped default", () => { + const persisted = [{ id: "claude", name: "Claude Code", command: "claude", icon: "claude" }]; + const result = resolveTerminalProfiles(persisted); + expect(result[0]).toEqual({ + id: "claude", + name: "Claude Code", + command: "claude", + icon: "claude", + args: [PROMPT_SENTINEL], + }); + }); + + // Profiles created through the settings UI get generated ids, so keying the + // adoption on the id would never match a real user's config. + it("appends the sentinel for a generated id, keying on the command instead", () => { + const persisted = [ + { id: "profile_1739308800000_a1b2c3", name: "Codex", command: "codex", args: ["--yolo"] }, + ]; + const result = resolveTerminalProfiles(persisted); + expect(result[0]?.args).toEqual(["--yolo", PROMPT_SENTINEL]); + }); + + it("appends the sentinel after the user's own args, preserving them", () => { + const persisted = [{ id: "codex", name: "Codex", command: "codex", args: ["--yolo"] }]; + const result = resolveTerminalProfiles(persisted); + expect(result[0]?.args).toEqual(["--yolo", PROMPT_SENTINEL]); + }); + + it("appends the sentinel for an absolute path to a known agent", () => { + const persisted = [ + { id: "profile_1739308800001_d4e5f6", name: "Codex", command: "/usr/local/bin/codex" }, + ]; + const result = resolveTerminalProfiles(persisted); + expect(result[0]?.args).toEqual([PROMPT_SENTINEL]); + }); + + it("appends the sentinel for a Windows-shaped command with an extension", () => { + const persisted = [ + { + id: "profile_1739308800002_g7h8i9", + name: "Claude Code", + command: "C:\\Program Files\\nodejs\\claude.cmd", + }, + ]; + const result = resolveTerminalProfiles(persisted); + expect(result[0]?.args).toEqual([PROMPT_SENTINEL]); + }); + + it("does not append when the profile already carries a sentinel", () => { + const persisted = [ + { id: "codex", name: "Codex", command: "codex", args: [PROMPT_SENTINEL, "--yolo"] }, + ]; + const result = resolveTerminalProfiles(persisted); + expect(result[0]).toBe(persisted[0]); + }); + + it("leaves a command that is not a known agent untouched, whatever its id", () => { + const persisted = [ + { id: "zsh-custom", name: "Zsh", command: "zsh" }, + { id: "claude", name: "Gemini", command: "gemini" }, + ]; + const result = resolveTerminalProfiles(persisted); + expect(result[0]).toBe(persisted[0]); + expect(result[1]).toBe(persisted[1]); + }); + + it("migrates only the matching entries in a mixed list, preserving the rest by reference", () => { + const otherAgent = { id: "profile_1739308800003_j1k2l3", name: "Gemini", command: "gemini" }; + const persisted = [{ id: "claude", name: "Claude Code", command: "claude" }, otherAgent]; + const result = resolveTerminalProfiles(persisted); + expect(result[0]?.args).toEqual([PROMPT_SENTINEL]); + expect(result[1]).toBe(otherAgent); }); it("default profiles include claude, codex, opencode", () => { @@ -35,6 +110,59 @@ describe("resolveTerminalProfiles", () => { expect(claude?.icon).toBe("claude"); }); + it("every default profile takes a prompt, and drops the arg entirely without one", () => { + for (const profile of DEFAULT_TERMINAL_PROFILES) { + expect(profileTakesPrompt(profile)).toBe(true); + // No prompt has to mean no argv entry at all: a bare positional would be + // an empty argument, and a bare `--prompt=` is not the same as omitting it. + expect(substitutePrompt(profile, "")).toEqual({ command: profile.command, args: [] }); + } + }); + + it("ships the prompt form each CLI actually accepts", () => { + const byId = new Map(DEFAULT_TERMINAL_PROFILES.map((profile) => [profile.id, profile])); + // claude, codex, and pi take the prompt as a trailing positional. + for (const id of ["claude", "codex", "pi"]) { + expect(byId.get(id)?.args).toEqual(["{{{prompt}}}"]); + } + // opencode's positional is the project directory, so the prompt is a flag. + expect(byId.get("opencode")?.args).toEqual(["--prompt={{{prompt}}}"]); + }); + + it("adopts the right prompt form per command, not a blanket trailing positional", () => { + const [adoptedOpencode] = resolveTerminalProfiles([ + { id: "profile_generated", name: "OpenCode", command: "opencode" }, + ]); + expect(adoptedOpencode?.args).toEqual(["--prompt={{{prompt}}}"]); + + const [adoptedPi] = resolveTerminalProfiles([ + { id: "profile_generated", name: "Pi", command: "pi", args: ["--thinking", "high"] }, + ]); + expect(adoptedPi?.args).toEqual(["--thinking", "high", "{{{prompt}}}"]); + }); + + it.each([ + ["codex", ["--profile", "work", "login"]], + ["opencode", ["--model", "anthropic/claude", "run"]], + ])("does not adopt %s when a subcommand follows global options", (command, args) => { + const persisted = { id: "profile_generated", name: command, command, args }; + const [resolved] = resolveTerminalProfiles([persisted]); + + expect(resolved).toBe(persisted); + }); + + it.each([ + ["codex", ["--profile", "work"]], + ["opencode", ["--model", "anthropic/claude"]], + ["pi", ["--thinking", "high"]], + ])("adopts %s when global options are the only existing args", (command, args) => { + const [resolved] = resolveTerminalProfiles([ + { id: "profile_generated", name: command, command, args }, + ]); + + expect(resolved?.args).toEqual([...args, expect.stringContaining("{{{prompt}}}")]); + }); + it("codex profile has the correct icon", () => { const codex = DEFAULT_TERMINAL_PROFILES.find((p) => p.id === "codex"); expect(codex?.icon).toBe("codex"); @@ -95,3 +223,76 @@ describe("getTerminalProfileIcon", () => { expect(getTerminalProfileIcon(profile)).toBeUndefined(); }); }); + +describe("profileTakesPrompt", () => { + it("is true when the sentinel is a standalone arg", () => { + expect(profileTakesPrompt({ command: "claude", args: [PROMPT_SENTINEL] })).toBe(true); + }); + + it("is true when the sentinel is embedded inside a larger arg", () => { + expect(profileTakesPrompt({ command: "sh", args: ["-c", `echo ${PROMPT_SENTINEL}`] })).toBe( + true, + ); + }); + + it("is true when the sentinel is in the command", () => { + expect(profileTakesPrompt({ command: `run ${PROMPT_SENTINEL}` })).toBe(true); + }); + + it("is false with no sentinel anywhere, including with args omitted", () => { + expect(profileTakesPrompt({ command: "claude", args: ["--continue"] })).toBe(false); + expect(profileTakesPrompt({ command: "zsh" })).toBe(false); + }); +}); + +describe("substitutePrompt", () => { + it("replaces a standalone sentinel arg with the prompt", () => { + expect(substitutePrompt({ command: "claude", args: [PROMPT_SENTINEL] }, "fix the bug")).toEqual( + { command: "claude", args: ["fix the bug"] }, + ); + }); + + it("drops a sentinel-only arg when the prompt is empty, so a bare command still launches", () => { + expect(substitutePrompt({ command: "claude", args: [PROMPT_SENTINEL] }, "")).toEqual({ + command: "claude", + args: [], + }); + }); + + it("substitutes into an embedded sentinel rather than dropping the arg, even when empty", () => { + expect( + substitutePrompt({ command: "sh", args: ["-c", `echo ${PROMPT_SENTINEL}`] }, ""), + ).toEqual({ command: "sh", args: ["-c", "echo "] }); + }); + + it("replaces every occurrence, in the command and in the args", () => { + expect( + substitutePrompt( + { command: `${PROMPT_SENTINEL}-run`, args: [`${PROMPT_SENTINEL}/${PROMPT_SENTINEL}`] }, + "x", + ), + ).toEqual({ command: "x-run", args: ["x/x"] }); + }); + + it("passes the prompt through verbatim, with no quoting or escaping", () => { + expect( + substitutePrompt({ command: "sh", args: [PROMPT_SENTINEL] }, 'a "quoted" $var; rm -rf'), + ).toEqual({ command: "sh", args: ['a "quoted" $var; rm -rf'] }); + }); + + it("normalizes absent args to an empty list", () => { + expect(substitutePrompt({ command: "zsh" }, "ignored")).toEqual({ command: "zsh", args: [] }); + }); +}); + +describe("formatResolvedCommand", () => { + it("joins the command and its args with spaces", () => { + expect(formatResolvedCommand({ command: "/bin/sh", args: ["-c", "echo hi"] })).toBe( + "/bin/sh -c echo hi", + ); + }); + + it("is just the command when there are no args", () => { + expect(formatResolvedCommand({ command: "zsh", args: [] })).toBe("zsh"); + }); +}); diff --git a/packages/protocol/src/terminal-profiles.ts b/packages/protocol/src/terminal-profiles.ts index 624247330..4a811437a 100644 --- a/packages/protocol/src/terminal-profiles.ts +++ b/packages/protocol/src/terminal-profiles.ts @@ -1,12 +1,110 @@ import type { TerminalProfile } from "./messages.js"; import { KNOWN_PROVIDER_ICON_NAMES } from "./provider-icon-names.js"; +/** + * Marks where a typed prompt goes inside a profile's `command` or `args`. A + * profile carrying it accepts a prompt; one without it launches as-is. + * + * Substitution happens client-side before `create_terminal_request` is sent, so + * this is profile-format vocabulary rather than anything on the wire. It lives + * here because this module owns what a `TerminalProfile` means. + */ +export const PROMPT_SENTINEL = "{{{prompt}}}"; + +// Prompt forms are taken from each CLI's own `--help`. claude, codex, and pi +// take the prompt as a trailing positional. opencode's positional is the +// project directory, so its prompt goes through `--prompt`, written in the +// `--flag=value` form to keep it one argv entry that can be dropped whole when +// no prompt is typed. export const DEFAULT_TERMINAL_PROFILES: readonly TerminalProfile[] = [ - { id: "claude", name: "Claude Code", command: "claude", icon: "claude" }, - { id: "codex", name: "Codex", command: "codex", icon: "codex" }, - { id: "opencode", name: "OpenCode", command: "opencode", icon: "opencode" }, + { id: "claude", name: "Claude Code", command: "claude", args: [PROMPT_SENTINEL], icon: "claude" }, + { id: "codex", name: "Codex", command: "codex", args: [PROMPT_SENTINEL], icon: "codex" }, + { + id: "opencode", + name: "OpenCode", + command: "opencode", + args: [`--prompt=${PROMPT_SENTINEL}`], + icon: "opencode", + }, + { id: "pi", name: "Pi", command: "pi", args: [PROMPT_SENTINEL], icon: "pi" }, ]; +export interface SubstitutableCommand { + command: string; + args?: string[]; +} + +export interface ResolvedCommand { + command: string; + args: string[]; +} + +function containsSentinel(value: string): boolean { + return value.includes(PROMPT_SENTINEL); +} + +/** True when the sentinel appears anywhere in `command` or an `args` entry. */ +export function profileTakesPrompt(profile: SubstitutableCommand): boolean { + return containsSentinel(profile.command) || (profile.args ?? []).some(containsSentinel); +} + +function replaceSentinel(value: string, prompt: string): string { + return value.split(PROMPT_SENTINEL).join(prompt); +} + +/** + * An arg that exists only to carry a prompt: the sentinel alone + * (`{{{prompt}}}`), or as the whole value of an option assignment + * (`--prompt={{{prompt}}}`). Both are dropped when there is no prompt, because + * an empty positional and a valueless `--prompt=` are not the same as omitting + * them. A sentinel embedded in larger text (`echo {{{prompt}}}`) is not + * prompt-only and still substitutes to empty. + * + * This is why an option and its value belong in one entry. Split across two, + * the option would survive with nothing to carry. + */ +function isPromptOnlyArg(arg: string): boolean { + if (arg === PROMPT_SENTINEL) { + return true; + } + const separator = arg.indexOf("="); + return separator > 0 && arg.slice(separator + 1) === PROMPT_SENTINEL; +} + +/** Replaces every sentinel occurrence with `prompt`, dropping prompt-only args when there is none. */ +export function substitutePrompt(profile: SubstitutableCommand, prompt: string): ResolvedCommand { + return { + command: replaceSentinel(profile.command, prompt), + args: (profile.args ?? []).flatMap((arg) => + prompt === "" && isPromptOnlyArg(arg) ? [] : [replaceSentinel(arg, prompt)], + ), + }; +} + +/** Human-readable preview of the resolved command, for read-only display. */ +export function formatResolvedCommand(resolved: ResolvedCommand): string { + return [resolved.command, ...resolved.args].join(" "); +} + +export interface TerminalProfileLaunch { + name: string; + command: string; + args: string[]; +} + +/** + * What to spawn for a profile. Every launcher goes through here, so no caller + * can forward a raw `profile.args` still carrying the sentinel: launchers with + * nowhere to type (pinned targets, the workspace terminal menu) pass an empty + * prompt and get the bare command back. + */ +export function resolveTerminalProfileLaunch( + profile: TerminalProfile, + prompt: string, +): TerminalProfileLaunch { + return { name: profile.name, ...substitutePrompt(profile, prompt) }; +} + const WELL_KNOWN_COMMAND_ICONS = new Map(KNOWN_PROVIDER_ICON_NAMES.map((name) => [name, name])); function getCommandBaseName(command: string): string { @@ -26,11 +124,193 @@ export function getTerminalProfileIcon(profile: TerminalProfile): string | undef return profile.icon ?? guessTerminalProfileIcon(profile.command); } +// Base command name to the sentinel-bearing args that command wants, derived +// from the shipped defaults rather than written out again, so adding a default +// profile later cannot silently miss the adoption below or get the wrong form. +const PROMPT_ARGS_BY_COMMAND = new Map( + DEFAULT_TERMINAL_PROFILES.map((profile) => [ + getCommandBaseName(profile.command), + (profile.args ?? []).filter(containsSentinel), + ]), +); + +// Adoption applies only to the interactive, prompt-taking form of each CLI. +// These are the global options whose values would otherwise look positional +// while walking argv. Unknown options stay conservative: a following bare arg +// blocks adoption rather than risking a second prompt or changing a subcommand. +const GLOBAL_OPTIONS_WITH_VALUES_BY_COMMAND = new Map>([ + [ + "claude", + new Set([ + "--add-dir", + "--agent", + "--agents", + "--allowed-tools", + "--allowedTools", + "--append-system-prompt", + "--betas", + "--debug-file", + "--disallowed-tools", + "--disallowedTools", + "--effort", + "--fallback-model", + "--file", + "--input-format", + "--json-schema", + "--max-budget-usd", + "--mcp-config", + "--model", + "--name", + "--output-format", + "--permission-mode", + "--plugin-dir", + "--plugin-url", + "--remote-control-session-name-prefix", + "--settings", + "--system-prompt", + "--system-prompt-file", + "--tools", + "-n", + ]), + ], + [ + "codex", + new Set([ + "--add-dir", + "--ask-for-approval", + "--cd", + "--config", + "--disable", + "--enable", + "--image", + "--local-provider", + "--model", + "--profile", + "--remote", + "--remote-auth-token-env", + "--sandbox", + "-C", + "-a", + "-c", + "-i", + "-m", + "-p", + "-s", + ]), + ], + [ + "opencode", + new Set([ + "--agent", + "--cors", + "--hostname", + "--log-level", + "--mdns-domain", + "--model", + "--port", + "--prompt", + "--replay-limit", + "--session", + "-m", + "-s", + ]), + ], + [ + "pi", + new Set([ + "--api-key", + "--append-system-prompt", + "--exclude-tools", + "--export", + "--extension", + "--fork", + "--mode", + "--model", + "--models", + "--name", + "--prompt-template", + "--provider", + "--session", + "--session-dir", + "--session-id", + "--skill", + "--system-prompt", + "--theme", + "--thinking", + "--tools", + "-e", + "-n", + "-t", + "-xt", + ]), + ], +]); + +// Profiles predating the sentinel got materialized into user config the first +// time anyone touched the profile list (host-page.tsx patches the whole list on +// any add, edit, or reorder). Those users would otherwise be stuck with +// launch-only versions of the agents we ship, while a fresh install gets prompt +// support, so a profile still pointing at one of those agents adopts the +// trailing sentinel on read. +// +// Keyed on the command's base name, not the profile id: profiles created +// through the settings UI get generated ids (`profile__`), +// so a real user's Codex profile is never id `codex`. The base name also +// normalizes `/usr/local/bin/codex` and `codex.cmd` onto the same agent. +// +// User args are preserved: `codex --yolo` becomes `codex --yolo {{{prompt}}}`. +// The appended form comes from the shipped default for that command, so +// opencode gets `--prompt=` rather than a trailing positional, which it would +// read as a project directory. A profile that already carries the sentinel +// anywhere is left alone, and so is one pointing at any other command. Nothing +// is written back, so this stays a read-time adoption with no version stamp and +// no daemon-start hook. +// Any positional means this is not the bare interactive form we can safely +// adopt. It may be a subcommand (`codex --profile work login`) or an existing +// prompt/project argument. Option assignments carry their value inline; known +// split options consume the next argv entry before positional detection. +function hasPositionalArg(command: string, args: readonly string[]): boolean { + const optionsWithValues = GLOBAL_OPTIONS_WITH_VALUES_BY_COMMAND.get(command) ?? new Set(); + let expectsOptionValue = false; + + for (const arg of args) { + if (expectsOptionValue) { + expectsOptionValue = false; + continue; + } + if (arg === "--") { + return true; + } + if (!arg.startsWith("-")) { + return true; + } + + const separator = arg.indexOf("="); + const option = separator === -1 ? arg : arg.slice(0, separator); + expectsOptionValue = separator === -1 && optionsWithValues.has(option); + } + + return false; +} + +function adoptPromptSentinel(profile: TerminalProfile): TerminalProfile { + const command = getCommandBaseName(profile.command); + const promptArgs = PROMPT_ARGS_BY_COMMAND.get(command); + if (!promptArgs || promptArgs.length === 0) { + return profile; + } + const args = profile.args ?? []; + if (profileTakesPrompt(profile) || hasPositionalArg(command, args)) { + return profile; + } + return { ...profile, args: [...args, ...promptArgs] }; +} + export function resolveTerminalProfiles( terminalProfiles: TerminalProfile[] | undefined, ): readonly TerminalProfile[] { if (terminalProfiles === undefined) { return DEFAULT_TERMINAL_PROFILES; } - return terminalProfiles; + return terminalProfiles.map(adoptPromptSentinel); } diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 80ef72296..fb1eac78a 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -509,7 +509,7 @@ interface ControlledInterruptFixture { manager: AgentManager; session: ControlledInterruptSession; startForegroundRun(): Promise; - cleanup(): void; + cleanup(): Promise; } async function createControlledInterruptFixture(options: { @@ -553,7 +553,10 @@ async function createControlledInterruptFixture(options: { })(); await manager.waitForAgentRunStart(agent.id); }, - cleanup: () => rmSync(workdir, { recursive: true, force: true }), + async cleanup() { + await manager.closeAgent(agent.id); + rmSync(workdir, { recursive: true, force: true }); + }, }; } @@ -1618,7 +1621,7 @@ test("cancelAgentRun preserves running state when the provider interrupt hangs", expect(fixture.session.interruptCalled).toBe(true); expect(fixture.manager.getAgent(fixture.agentId)?.lifecycle).toBe("running"); } finally { - fixture.cleanup(); + await fixture.cleanup(); } }); @@ -1649,7 +1652,7 @@ test("cancelAgentRun preserves the active turn when the provider rejects the int turnId: "provider-still-active-turn", }); } finally { - fixture.cleanup(); + await fixture.cleanup(); } }); @@ -1682,7 +1685,7 @@ test("cancelAgentRun succeeds when the foreground turn finishes before the provi activeForegroundTurnId: null, }); } finally { - fixture.cleanup(); + await fixture.cleanup(); } }); @@ -1712,7 +1715,7 @@ test("cancelAgentRun succeeds when the provider queues completion before rejecti activeForegroundTurnId: null, }); } finally { - fixture.cleanup(); + await fixture.cleanup(); } }); diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index 70e60fe16..f4a7721c4 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -392,4 +392,4 @@ test("native recursive observation updates tracked state and prunes ignored stor expect(getCheckoutWorktreeState).not.toHaveBeenCalled(); expect(getCheckoutDiff).not.toHaveBeenCalled(); expect(service.getMetrics().workspaceRefreshQueuedCount).toBe(0); -}, 15_000); +}, 30_000); diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index 086ab6c86..9881f4bdf 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -23,9 +23,11 @@ import { writeFileSync, } from "node:fs"; import { spawnSync } from "node:child_process"; +import * as pty from "node-pty"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { setImmediate as waitForImmediate } from "node:timers/promises"; +import { setImmediate as waitForImmediate, setTimeout as delay } from "node:timers/promises"; +import { stripVTControlCharacters } from "node:util"; const hasZsh = existsSync("/bin/zsh"); @@ -116,16 +118,52 @@ if (isPlatform("win32") && !process.env.ComSpec && !process.env.COMSPEC) { const sessions: TerminalSession[] = []; const temporaryDirs: string[] = []; +/** + * `kill()` only signals; the shell exits asynchronously and keeps writing on + * the way out (zsh drops a history file and a compdump into `$ZDOTDIR`). + * Deleting its directory before then loses the race as `ENOTEMPTY`: the + * recursive walk empties the directory, the dying shell recreates a file in it, + * and the final rmdir fails. + * + * Waiting for the exit shrinks that window but does not close it: some of those + * writes come from processes zsh forked, which outlive the pty and answer to + * nothing we hold. Retries alone did not close it either. + * + * The known post-retry `ENOTEMPTY` race is reported and tolerated because it + * belongs to a descendant shell outliving the tested terminal. Any other + * cleanup error still fails the test that owns the directory. + */ +async function waitForSessionExit(session: TerminalSession): Promise { + const exited = new Promise((resolve) => { + const dispose = session.onExit(() => { + dispose(); + resolve(); + }); + session.kill(); + }); + await Promise.race([exited, delay(2_000)]); +} + afterEach(async () => { vi.useRealTimers(); - for (const session of sessions) { - session.kill(); - } + await Promise.all(sessions.map(waitForSessionExit)); sessions.length = 0; while (temporaryDirs.length > 0) { const dir = temporaryDirs.pop(); if (dir) { - rmSync(dir, { recursive: true, force: true }); + try { + rmSync(dir, { recursive: true, force: true, maxRetries: 20, retryDelay: 25 }); + } catch (error) { + if ( + typeof error !== "object" || + error === null || + !("code" in error) || + error.code !== "ENOTEMPTY" + ) { + throw error; + } + console.warn(`leaving temp dir behind after ENOTEMPTY retries: ${dir}`); + } } } }); @@ -216,6 +254,33 @@ describe("createTerminal", () => { expect(resolveExecutable).not.toHaveBeenCalled(); }); + it.runIf(isPlatform("linux", "darwin"))( + "passes an unescaped multi-line prompt through byte-for-byte on POSIX", + async () => { + // node-pty spawns directly via execve on POSIX (no shell), so a prompt + // substituted into `args` is never re-parsed by anything -- it must + // reach the target process exactly as typed, including raw newlines, + // quotes, and shell metacharacters that would be dangerous under a + // shell but are inert here. + const nastyPrompt = + 'fix the bug\nwhere `git commit -m "wip"` fails\nif $HOME contains a & or | char'; + const resolveExecutable = vi.fn(async () => "/usr/local/bin/claude"); + + const resolvedLinux = await resolveTerminalSpawnCommand("claude", [nastyPrompt], { + platform: "linux", + resolveExecutable, + }); + const resolvedDarwin = await resolveTerminalSpawnCommand("claude", [nastyPrompt], { + platform: "darwin", + resolveExecutable, + }); + + expect(resolvedLinux).toEqual({ command: "claude", args: [nastyPrompt] }); + expect(resolvedDarwin).toEqual({ command: "claude", args: [nastyPrompt] }); + expect(resolveExecutable).not.toHaveBeenCalled(); + }, + ); + it("keeps the original command when it cannot be resolved on Windows", async () => { const resolved = await resolveTerminalSpawnCommand("claude", [], { platform: "win32", @@ -233,9 +298,77 @@ describe("createTerminal", () => { resolveExecutable: async () => "C:\\npm\\claude.cmd", }); + // The `.cmd` branch now hands node-pty a single pre-escaped command line + // (string) instead of an argv array, so that node-pty's own + // MSVCRT-style array quoting never runs a second time over an already + // cmd.exe-escaped argument. See escapeCmdExeArgument in terminal.ts. expect(resolved).toEqual({ command: "C:\\Windows\\System32\\cmd.exe", - args: ["/c", "C:\\npm\\claude.cmd", "--foo"], + args: '/d /s /c "C:\\npm\\claude.cmd ^^^"--foo^^^""', + }); + }); + + describe(".cmd shim argument escaping on Windows", () => { + async function resolveCmdShimArgs(promptArg: string): Promise { + const resolved = await resolveTerminalSpawnCommand("claude", [promptArg], { + platform: "win32", + env: { ComSpec: "C:\\Windows\\System32\\cmd.exe" }, + resolveExecutable: async () => "C:\\npm\\claude.cmd", + }); + // A real narrow rather than a cast: the `.cmd` branch is the one case + // that returns a pre-escaped command line instead of argv, so if that + // contract ever changes these tests should fail loudly here rather than + // assert against a stringified array. + const { args } = resolved; + if (typeof args !== "string") { + throw new Error(`expected a cmd.exe command line, got ${JSON.stringify(args)}`); + } + return args; + } + + // Each expected string is the exact cmd.exe command line the escaping + // must produce so a real `cmd.exe /c` invocation reconstructs the + // original prompt as a single argv element for the target process + // (modulo the documented newline-to-space normalization below). + it("neutralizes cmd.exe's own metacharacters instead of letting them execute", async () => { + expect(await resolveCmdShimArgs("a & b | c")).toBe( + '/d /s /c "C:\\npm\\claude.cmd ^^^"a^^^ ^^^&^^^ b^^^ ^^^|^^^ c^^^""', + ); + expect(await resolveCmdShimArgs("100% done %PATH%")).toBe( + '/d /s /c "C:\\npm\\claude.cmd ^^^"100^^^%^^^ done^^^ ^^^%PATH^^^%^^^""', + ); + expect(await resolveCmdShimArgs("a^b")).toBe('/d /s /c "C:\\npm\\claude.cmd ^^^"a^^^^b^^^""'); + }); + + it("escapes embedded double quotes and backslash runs preceding them", async () => { + expect(await resolveCmdShimArgs('say "hello" now')).toBe( + '/d /s /c "C:\\npm\\claude.cmd ^^^"say^^^ \\^^^"hello\\^^^"^^^ now^^^""', + ); + expect(await resolveCmdShimArgs('C:\\path\\ "quoted"')).toBe( + '/d /s /c "C:\\npm\\claude.cmd ^^^"C:\\path\\^^^ \\^^^"quoted\\^^^"^^^""', + ); + }); + + // cmd.exe has no way to escape a literal CR/LF inside one command line + // -- its parser treats newlines as command separators regardless of + // quoting -- so a raw newline is normalized to a space before escaping. + // This is a deliberate choice, not an oversight: it avoids the prompt + // ever splitting into a second, attacker-controlled cmd.exe command, at + // the cost of losing the original line breaks. + it("normalizes embedded newlines to spaces instead of letting them split the command", async () => { + expect(await resolveCmdShimArgs("line one\nline two")).toBe( + '/d /s /c "C:\\npm\\claude.cmd ^^^"line^^^ one^^^ line^^^ two^^^""', + ); + expect(await resolveCmdShimArgs("line one\r\nline two")).toBe( + '/d /s /c "C:\\npm\\claude.cmd ^^^"line^^^ one^^^ line^^^ two^^^""', + ); + }); + + it("handles a multi-line prompt combining quotes, backslashes, and every cmd.exe metacharacter", async () => { + const prompt = 'multi\nline "quoted \\path\\" & piped | percent %VAR% ^caret'; + expect(await resolveCmdShimArgs(prompt)).toBe( + '/d /s /c "C:\\npm\\claude.cmd ^^^"multi^^^ line^^^ \\^^^"quoted^^^ \\path\\\\\\^^^"^^^ ^^^&^^^ piped^^^ ^^^|^^^ percent^^^ ^^^%VAR^^^%^^^ ^^^^caret^^^""', + ); }); }); @@ -248,6 +381,35 @@ describe("createTerminal", () => { expect(resolved).toEqual({ command: "C:\\tools\\claude.exe", args: ["--foo"] }); }); + it.runIf(isPlatform("linux", "darwin"))( + "spawns without shell interpretation on POSIX, so argv reaches the child byte-for-byte", + async () => { + // The composer substitutes {{{prompt}}} directly into `args` before this + // ever reaches the daemon, so a raw multi-line prompt containing quotes, + // backslashes, and shell metacharacters must arrive at the spawned + // process exactly as typed. node-pty's POSIX backend spawns via execve + // with no shell, so per-arg escaping is unnecessary there -- this test + // proves that end-to-end through createTerminal (not just at the + // resolveTerminalSpawnCommand unit level) by reading the argv the + // spawned process actually received back out of the pty. + const nastyPrompt = + 'line one\nline two: git commit -m "wip" && echo $HOME | grep ^x > out.txt'; + const session = trackSession( + await createTerminal({ + workspaceId: "ws-test", + cwd: realpathSync(tmpdir()), + cols: 500, + command: process.execPath, + args: ["-e", "process.stdout.write(JSON.stringify(process.argv.slice(1)))", nastyPrompt], + }), + ); + + const state = await waitForState(session, (s) => getRowText(s, 0).startsWith("[")); + + expect(JSON.parse(getRowText(state, 0))).toEqual([nastyPrompt]); + }, + ); + it("creates a terminal session with an id, name, and cwd", async () => { const session = trackSession( await createTerminal({ @@ -1096,3 +1258,89 @@ describe("terminal activity interruption", () => { expect(session.getActivity()).toMatchObject({ state: "working" }); }); }); + +// The escaping tests above assert the command line we generate. This one runs +// it: a real cmd.exe launches a real .cmd shim, which echoes the argv it was +// handed. It is the only check that proves cmd.exe's tokenizer reconstructs +// the prompt instead of executing part of it, which is the failure the +// escaping exists to prevent (the CVE-2024-27980 class). +describe.runIf(isPlatform("win32"))(".cmd shim argv round-trip on Windows", () => { + const argvMarker = "__PASEO_ARGV__"; + + function writeArgvEchoShim(): { dir: string; cmdPath: string } { + const dir = mkdtempSync(join(tmpdir(), "paseo-cmd-shim-")); + temporaryDirs.push(dir); + const cmdPath = join(dir, "echoargv.cmd"); + const scriptPath = join(dir, "echoargv.js"); + writeFileSync( + scriptPath, + `process.stdout.write(${JSON.stringify(argvMarker)} + JSON.stringify(process.argv.slice(2)) + "\\n");\n`, + ); + writeFileSync(cmdPath, `@echo off\r\n"${process.execPath}" "${scriptPath}" %*\r\n`); + return { dir, cmdPath }; + } + + function runCommandLine(command: string, args: string, cwd: string): Promise { + return new Promise((resolve, reject) => { + const terminal = pty.spawn(command, args, { cwd, cols: 500, rows: 30 }); + let output = ""; + const dataDisposable = terminal.onData((data) => { + output += data; + }); + const timeout = setTimeout(() => { + terminal.kill(); + reject(new Error("Timed out waiting for cmd.exe fixture")); + }, 10_000); + timeout.unref(); + terminal.onExit(({ exitCode }) => { + clearTimeout(timeout); + dataDisposable.dispose(); + if (exitCode !== 0) { + reject(new Error(`cmd.exe fixture exited ${exitCode}: ${output}`)); + return; + } + resolve(output); + }); + }); + } + + async function roundTrip(promptArg: string): Promise { + const { dir, cmdPath } = writeArgvEchoShim(); + const resolved = await resolveTerminalSpawnCommand("echoargv", [promptArg], { + platform: "win32", + env: process.env, + resolveExecutable: async () => cmdPath, + }); + if (typeof resolved.args !== "string") { + throw new Error(`expected a cmd.exe command line, got ${JSON.stringify(resolved.args)}`); + } + const output = await runCommandLine(resolved.command, resolved.args, dir); + const cleanOutput = stripVTControlCharacters(output); + const markerIndex = cleanOutput.lastIndexOf(argvMarker); + if (markerIndex === -1) { + throw new Error(`expected argv marker, got ${output}`); + } + const argvJson = cleanOutput.slice(markerIndex + argvMarker.length).split(/\r?\n/, 1)[0]; + const argv: unknown = JSON.parse(argvJson); + if (!Array.isArray(argv) || !argv.every((arg) => typeof arg === "string")) { + throw new Error(`expected string argv, got ${output}`); + } + return argv; + } + + it("passes cmd.exe metacharacters through as literal text", async () => { + expect(await roundTrip("a & b | c")).toEqual(["a & b | c"]); + expect(await roundTrip("100% done %PATH%")).toEqual(["100% done %PATH%"]); + expect(await roundTrip("a^b")).toEqual(["a^b"]); + expect(await roundTrip('say "hi"')).toEqual(['say "hi"']); + expect(await roundTrip("c:\\path\\to\\thing")).toEqual(["c:\\path\\to\\thing"]); + }); + + it("does not let a metacharacter start a second command", async () => { + expect(await roundTrip("safe & echo pwned")).toEqual(["safe & echo pwned"]); + }); + + it("normalizes newlines to spaces rather than splitting the command", async () => { + expect(await roundTrip("first\nsecond")).toEqual(["first second"]); + }); +}); diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 8668caf09..b9d1b7401 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -247,7 +247,15 @@ export function resolveDefaultTerminalShell( export interface ResolvedTerminalCommand { command: string; - args: string[]; + // `.cmd`/`.bat` targets resolve to a single pre-escaped `cmd.exe` command + // line (string) instead of an argv array. node-pty's own array-quoting + // (see `argsToCommandLine` in its `windowsPtyAgent.js`) only produces + // MSVCRT/CommandLineToArgvW-safe quoting; cmd.exe re-parses that whole + // line itself afterwards and needs a different, cmd.exe-metachar-aware + // escape. Building that line ourselves and passing it as a string is the + // node-pty-documented way to bypass its array quoting for exactly this + // case (see the `spawn` jsdoc in node-pty's typings). + args: string[] | string; } export interface ResolveTerminalSpawnCommandOptions { @@ -256,6 +264,66 @@ export interface ResolveTerminalSpawnCommandOptions { resolveExecutable?: (name: string) => Promise; } +// cmd.exe treats these characters specially even inside a double-quoted +// argument, unlike a POSIX shell — quoting alone does not neutralize them. +// Prefixing each with `^`, cmd.exe's own escape character, does. Mirrors the +// `escape.command` metachar list in the `cross-spawn` npm package (already a +// dependency of this repo), which solves this exact `.cmd`/`.bat` spawning +// problem — see http://www.robvanderwoude.com/escapechars.php. +const CMD_EXE_METACHAR_PATTERN = /([()%!^"`<>&|;, *?])/g; + +function escapeCmdExeMetaChars(text: string): string { + return text.replace(CMD_EXE_METACHAR_PATTERN, "^$1"); +} + +/** + * Escape one argument for safe inclusion in a `cmd.exe /c "..."` command + * line that is about to invoke a `.cmd`/`.bat` shim. + * + * node-pty builds an MSVCRT/`CommandLineToArgvW`-style quoted command line + * for whatever `command` it is given (double-quote wrap on whitespace, + * backslash-escape only backslash runs immediately before a quote). That + * quoting is correct for a normal executable, which parses its own argv via + * that same convention. It is not correct here: the `command` node-pty + * launches for a `.cmd`/`.bat` target is `cmd.exe` itself, and cmd.exe + * re-parses the whole line with its *own* tokenizer before ever handing + * arguments to the batch script — a tokenizer that treats `&`, `|`, `^`, + * `%` (environment-variable expansion), `<`, `>` etc. as live syntax even + * inside double quotes. A prompt containing any of those characters would + * therefore be interpreted by cmd.exe, not passed through as text — the + * same class of hazard tracked upstream as Node's CVE-2024-27980 + * ("batBadBadging"). + * + * The escaping below is the "qntm" algorithm (https://qntm.org/cmd): double + * every backslash run that immediately precedes a quote (or the end of the + * argument, since a closing quote is appended next) and escape that quote, + * wrap the whole argument in quotes, then caret-escape cmd.exe's + * metacharacters — including the quotes just added, because cmd.exe's own + * tokenizer runs first and would otherwise treat them as toggling a quoted + * region. It is the same algorithm `cross-spawn` uses for its `.cmd`/`.bat` + * auto-shell path (`lib/util/escape.js`). Command shims forward `%*`, which + * adds a second cmd.exe parse, so metacharacters need a second escape pass to + * survive as literal argv in the Node process behind an npm-generated shim. + * + * cmd.exe has no escape for a literal CR or LF inside a single command + * line — its parser treats them as command separators regardless of + * quoting, so passing one through verbatim would let a crafted prompt + * inject a second, attacker-chosen command after the newline. There is no + * fidelity-preserving option for that case, so newlines are normalized to + * spaces before quoting: the target process still receives the full text, + * just without the original line breaks, instead of cmd.exe splitting the + * prompt into multiple commands. + */ +function escapeCmdExeArgument(rawArg: string): string { + const arg = rawArg.replace(/\r\n|\r|\n/g, " "); + + let escaped = arg.replace(/(\\*)"/g, '$1$1\\"'); + escaped = escaped.replace(/(\\*)$/, "$1$1"); + escaped = `"${escaped}"`; + + return escapeCmdExeMetaChars(escapeCmdExeMetaChars(escaped)); +} + /** * Resolve a terminal profile command (e.g. `claude`) into something node-pty's * conpty backend can actually launch on Windows. @@ -297,7 +365,21 @@ export async function resolveTerminalSpawnCommand( if (extension === ".cmd" || extension === ".bat") { const env = options.env ?? process.env; const comSpec = env.ComSpec || env.COMSPEC || "C:\\Windows\\System32\\cmd.exe"; - return { command: comSpec, args: ["/c", resolved, ...args] }; + // Build the `/c` command line ourselves — as a single pre-escaped + // string, not an argv array — so node-pty's own array quoting never + // runs over these values; see escapeCmdExeArgument for why that + // quoting alone is not safe once cmd.exe re-parses the line. `resolved` + // gets the same metachar (not quote-wrap) treatment as the args: it + // isn't attacker-controlled, but it now sits inside the same joined + // command line and needs to survive cmd.exe's tokenizer too (e.g. a + // space in the install path). + const commandLine = [ + "/d", + "/s", + "/c", + `"${[escapeCmdExeMetaChars(resolved), ...args.map(escapeCmdExeArgument)].join(" ")}"`, + ].join(" "); + return { command: comSpec, args: commandLine }; } return { command: resolved, args }; diff --git a/packages/server/vitest.config.ts b/packages/server/vitest.config.ts index 9f3717a9b..4b2576444 100644 --- a/packages/server/vitest.config.ts +++ b/packages/server/vitest.config.ts @@ -19,6 +19,9 @@ export default defineConfig({ setupFiles: [path.resolve(__dirname, "./src/test-utils/vitest-setup.ts")], pool: "forks", fileParallelism: false, + // Windows runners intermittently starve subprocess-heavy Git tests at the + // default worker count, leaving child processes alive past their deadlines. + maxWorkers: process.platform === "win32" ? 2 : undefined, exclude: ["**/node_modules/**", "**/dist/**", "**/.claude/**", "**/.dev/**"], }, });