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/**"], }, });