Launch a terminal instead of a chat from New workspace (#2941)

* feat(app): launch a terminal from the New workspace screen

New workspace only ever started a chat agent. Empty workspaces existed but
were reachable only by submitting an empty composer, which nothing advertised,
and terminals could not be started from this screen at all. People concluded
Paseo did not support terminals.

Adds a launch chip to the meta row, shaped from the same ComboboxTrigger as
its neighbours, that chooses between Chat and a terminal. Terminal profiles
mark where a typed prompt goes with a {{{prompt}}} sentinel in their command
or args, substituted client-side before create_terminal_request, so the daemon
learns nothing new and the protocol is untouched. A profile without the
sentinel takes no input: the composer goes read-only and shows the exact
command, so there is nothing to type wrong.

Prompt forms come from each CLI's own --help. claude, codex, and pi take a
trailing positional; opencode's positional is the project directory, so it
uses --prompt=. Profiles predating the sentinel adopt the right form on read,
keyed on the command's base name rather than the profile id, because profiles
created in settings get generated ids.

* fix(app): stop existing launchers passing the prompt sentinel literally

Adoption made every caller of resolveTerminalProfiles see sentinel-bearing
args, but only New workspace substituted them. Pinned targets and the
workspace terminal menu forwarded profile.args straight through, so launching
Claude from an existing workspace ran `claude "{{{prompt}}}"` with that text as
the prompt.

Every launcher now goes through resolveTerminalProfileLaunch. Surfaces with
nowhere to type pass an empty prompt, which drops the prompt-only arg and
gives back the bare command they had before.

Also from the same review:

- Adoption skipped when args lead with a subcommand. `opencode run` takes a
  positional message rather than --prompt, and `codex login` takes no prompt at
  all. Only the first arg is checked, so `pi --thinking high` still adopts.
- Settings edits the persisted list again, not the adopted view. It writes the
  whole list back on any change, so resolving first baked read-time adoption
  into the user's config the moment they reordered a row.
- A hidden draft no longer names a workspace. Text typed for a prompt-taking
  profile stayed in state after switching to a read-only one and still seeded
  the generated title and branch.
- Windows .cmd escaping now has a round-trip test: a real cmd.exe runs a real
  .cmd shim that echoes its argv, covering metacharacters, an injection
  attempt, and newlines. The existing tests only asserted the command line we
  generate.
- Fixed an ENOTEMPTY flake in the terminal suite. kill() only signals, so the
  shell was still writing into $ZDOTDIR while its directory was being removed.
  Cleanup waits for exit instead.

* fix(app): keep the host and isolation accessible names stable

Wiring the new tooltips also replaced these two accessible names with the
tooltip copy, which broke `sidebar-project-grouping` at the host picker: its
selector is `getByRole("button", { name: "Host", exact: true })` and the button
had become "Choose the host". It timed out on both attempts in CI.

An accessible name identifies a control; a tooltip explains it. Conflating them
renamed a control that other surfaces address by name. The names go back to
what they were and the tooltips keep the "Choose the ..." copy.

* test(server): stop temp-dir teardown failing unrelated terminal tests

The ENOTEMPTY flake survived both earlier attempts. Waiting for the session to
exit shrank the window; retrying the remove did not close it either. The writes
come from processes zsh forks on the way out, which outlive the pty and answer
to nothing the test holds.

Teardown is now best-effort and logs what it left behind. A temp directory we
cannot remove is not a defect in the terminal code under test, and failing on it
reports the race against whichever assertion ran last, which is exactly how this
surfaced: as a flake in an unrelated zsh title test. 10 consecutive runs green.

Also replaces a cast in the Windows .cmd helper with a real narrow, so a change
to ResolvedTerminalCommand fails loudly there. Test files are excluded from
typecheck (tsconfig.server.json), so a cast in one is never checked at all.

* fix(app): keep terminal launch policy in its owning workflows

Profile adoption now parses known global option values before deciding whether argv already contains a positional. Workspace naming is derived inside the terminal workflow so hidden launch-only drafts cannot affect branch or title generation.

* fix(server): preserve cmd shim arguments through both parses

npm command shims forward %*, so metacharacters lose one escape layer before the target process receives argv. Exercise the production node-pty string command line against a forwarding shim, and only tolerate the known ENOTEMPTY teardown race.

* fix(server): stabilize Windows subprocess tests

* test(server): isolate Windows argv recorder output

* test(server): allow native watcher integration to settle
This commit is contained in:
Mohamed Boudra
2026-08-07 14:08:22 +08:00
committed by GitHub
parent 292a9c4c8c
commit c352632b74
36 changed files with 2929 additions and 154 deletions
+278 -70
View File
@@ -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 (
<View style={styles.chevronContainer}>
<ThemedChevronDown size={ICON_SIZE.sm} uniProps={chevronExtraMutedMapping} />
</View>
);
}
const metaChevron = <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({
<Tooltip>
<TooltipTrigger asChild triggerRefProp="ref">
<ComboboxTrigger
chevron={metaChevron}
ref={pickerAnchorRef}
testID="new-workspace-ref-picker-trigger"
onPress={onPress}
@@ -260,6 +291,7 @@ function ProjectPickerTrigger({
disabled,
badgePressableStyle,
label,
tooltipLabel,
projectViewKey,
iconDataUri,
iconColor,
@@ -270,6 +302,7 @@ function ProjectPickerTrigger({
disabled: boolean;
badgePressableStyle: React.ComponentProps<typeof Pressable>["style"];
label: string;
tooltipLabel: string;
projectViewKey: string | null;
iconDataUri: string | null;
iconColor: string;
@@ -281,6 +314,7 @@ function ProjectPickerTrigger({
<Tooltip>
<TooltipTrigger asChild triggerRefProp="ref">
<ComboboxTrigger
chevron={metaChevron}
ref={pickerAnchorRef}
testID="new-workspace-project-picker-trigger"
onPress={onPress}
@@ -308,7 +342,7 @@ function ProjectPickerTrigger({
</ComboboxTrigger>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Choose project</Text>
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
</TooltipContent>
</Tooltip>
);
@@ -592,6 +626,7 @@ function IsolationPickerTrigger({
badgePressableStyle,
isolation,
label,
tooltipLabel,
iconColor,
iconSize,
}: {
@@ -601,30 +636,39 @@ function IsolationPickerTrigger({
badgePressableStyle: React.ComponentProps<typeof Pressable>["style"];
isolation: "local" | "worktree";
label: string;
tooltipLabel: string;
iconColor: string;
iconSize: number;
}) {
return (
<ComboboxTrigger
ref={pickerAnchorRef}
testID="workspace-create-isolation-trigger"
onPress={onPress}
disabled={disabled}
style={badgePressableStyle}
accessibilityRole="button"
accessibilityLabel="Workspace isolation"
>
<View style={styles.badgeIconBox}>
{isolation === "worktree" ? (
<GitBranch size={iconSize} color={iconColor} />
) : (
<Folder size={iconSize} color={iconColor} />
)}
</View>
<Text style={styles.badgeText} numberOfLines={1}>
{label}
</Text>
</ComboboxTrigger>
<Tooltip>
<TooltipTrigger asChild triggerRefProp="ref">
<ComboboxTrigger
chevron={metaChevron}
ref={pickerAnchorRef}
testID="workspace-create-isolation-trigger"
onPress={onPress}
disabled={disabled}
style={badgePressableStyle}
accessibilityRole="button"
accessibilityLabel="Workspace isolation"
>
<View style={styles.badgeIconBox}>
{isolation === "worktree" ? (
<GitBranch size={iconSize} color={iconColor} />
) : (
<Folder size={iconSize} color={iconColor} />
)}
</View>
<Text style={styles.badgeText} numberOfLines={1}>
{label}
</Text>
</ComboboxTrigger>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
</TooltipContent>
</Tooltip>
);
}
@@ -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}
>
<Pressable
ref={host.anchorRef}
accessibilityRole="button"
accessibilityLabel="Host"
onPress={host.open}
disabled={isPending || host.allHosts.length === 0}
style={badgePressableStyle}
testID="host-picker-trigger"
>
<View style={styles.badgeIconBox}>
<HostStatusDot serverId={host.selectedServerId} />
</View>
<Text style={styles.badgeText} numberOfLines={1}>
{selectedHostLabel}
</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<Tooltip>
<TooltipTrigger asChild triggerRefProp="ref">
<Pressable
ref={host.anchorRef}
accessibilityRole="button"
accessibilityLabel="Host"
onPress={host.open}
disabled={isPending || host.allHosts.length === 0}
style={badgePressableStyle}
testID="host-picker-trigger"
>
<View style={styles.badgeIconBox}>
<HostStatusDot serverId={host.selectedServerId} />
</View>
<Text style={styles.badgeText} numberOfLines={1}>
{selectedHostLabel}
</Text>
{metaChevron}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{t("newWorkspace.tooltips.host")}</Text>
</TooltipContent>
</Tooltip>
</HostPicker>
</View>
) : 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
</View>
) : null;
const launchControl = (
<LaunchControl
serverId={launch.serverId}
target={launch.target}
onChange={launch.onChange}
profiles={launch.profiles}
disabled={launch.disabled}
badgePressableStyle={badgePressableStyle}
/>
);
return isCompact ? (
<View testID="new-workspace-ref-picker-row" style={styles.formStack}>
<FormRow>{projectControl}</FormRow>
@@ -1440,6 +1511,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
<View style={styles.baseSpacer} />
)}
{baseControl ? <FormRow>{baseControl}</FormRow> : <View style={styles.baseSpacer} />}
<FormRow>{launchControl}</FormRow>
</View>
) : (
<View testID="new-workspace-ref-picker-row" style={styles.formStackDesktop}>
@@ -1447,6 +1519,8 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
{hostControl}
{isolationControl}
{baseControl}
<View style={styles.launchSpacer} />
{launchControl}
</View>
);
}
@@ -1488,7 +1562,7 @@ export function NewWorkspaceScreen({
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
typeof normalizeWorkspaceDescriptor
> | 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<View | null>(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<LaunchTarget | null>(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(() => <SidebarMenuToggle />, []);
@@ -2109,33 +2275,62 @@ export function NewWorkspaceScreen({
<Text style={styles.composerTitle}>{t("newWorkspace.title")}</Text>
</View>
{formStack}
<Composer
externalKeyboardShift
agentId={draftKey}
serverId={selectedServerId}
isPaneFocused={true}
onSubmitMessage={handleSubmitNewWorkspace}
allowEmptySubmit={true}
submitButtonAccessibilityLabel={t("newWorkspace.create")}
submitButtonTestID="workspace-create-submit"
submitIcon="return"
isSubmitLoading={isPending}
waitForGithubAutoAttachOnSubmit
submitBehavior="preserve-and-lock"
blurOnSubmit={true}
value={chatDraft.text}
onChangeText={chatDraft.setText}
attachments={chatDraft.attachments}
attachmentScopeKeys={visibleDraftContextScopeKeys}
onChangeAttachments={chatDraft.setAttachments}
onGithubPrDetected={handleGithubPrDetected}
onGithubPrAutoAttach={handleGithubPrAutoAttach}
cwd={selectedSourceDirectory ?? ""}
clearDraft={handleClearDraft}
autoFocus
commandDraftConfig={composerState?.commandDraftConfig}
agentControls={agentControlsWithDisabled}
/>
{isTerminalLaunch ? (
<Composer
externalKeyboardShift
inputMode="terminal"
readOnly={!terminalTakesPrompt}
placeholder={terminalPlaceholder}
submitLabel={terminalSubmitLabel}
agentId={draftKey}
serverId={selectedServerId}
isPaneFocused={true}
onSubmitMessage={handleSubmitTerminalLaunch}
allowEmptySubmit={true}
submitButtonAccessibilityLabel={t("newWorkspace.launch.submit")}
submitButtonTestID="new-workspace-launch-submit"
isSubmitLoading={isPending}
submitBehavior="preserve-and-lock"
blurOnSubmit={true}
value={terminalComposerValue}
onChangeText={setTerminalPromptText}
attachments={NO_TERMINAL_ATTACHMENTS}
onChangeAttachments={noopChangeAttachments}
cwd={selectedSourceDirectory ?? ""}
clearDraft={noopClearDraft}
autoFocus={terminalTakesPrompt}
autoFocusKey={launchFocusKey}
/>
) : (
<Composer
externalKeyboardShift
agentId={draftKey}
serverId={selectedServerId}
isPaneFocused={true}
onSubmitMessage={handleSubmitNewWorkspace}
allowEmptySubmit={true}
submitButtonAccessibilityLabel={t("newWorkspace.create")}
submitButtonTestID="workspace-create-submit"
submitIcon="return"
isSubmitLoading={isPending}
waitForGithubAutoAttachOnSubmit
submitBehavior="preserve-and-lock"
blurOnSubmit={true}
value={chatDraft.text}
onChangeText={chatDraft.setText}
attachments={chatDraft.attachments}
attachmentScopeKeys={visibleDraftContextScopeKeys}
onChangeAttachments={chatDraft.setAttachments}
onGithubPrDetected={handleGithubPrDetected}
onGithubPrAutoAttach={handleGithubPrAutoAttach}
cwd={selectedSourceDirectory ?? ""}
clearDraft={handleClearDraft}
autoFocus
autoFocusKey={launchFocusKey}
commandDraftConfig={composerState?.commandDraftConfig}
agentControls={agentControlsWithDisabled}
/>
)}
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
</ReanimatedAnimated.View>
</View>
@@ -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,
@@ -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,
});
});
});
@@ -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<ReturnType<typeof normalizeWorkspaceDescriptor>>;
createTerminal: (input: {
workspaceDirectory: string;
workspaceId: string;
name?: string;
command?: string;
args?: string[];
}) => Promise<CreatedTerminal>;
/** 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<void> {
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,
});
}
@@ -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],
);
@@ -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);