* 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
317 lines
10 KiB
TypeScript
317 lines
10 KiB
TypeScript
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", 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 {
|
|
const lastSlash = command.lastIndexOf("/");
|
|
const lastBackslash = command.lastIndexOf("\\");
|
|
const start = Math.max(lastSlash, lastBackslash) + 1;
|
|
const base = command.slice(start).toLowerCase();
|
|
const dotIndex = base.indexOf(".");
|
|
return dotIndex > 0 ? base.slice(0, dotIndex) : base;
|
|
}
|
|
|
|
export function guessTerminalProfileIcon(command: string): string | undefined {
|
|
return WELL_KNOWN_COMMAND_ICONS.get(getCommandBaseName(command));
|
|
}
|
|
|
|
export function getTerminalProfileIcon(profile: TerminalProfile): string | undefined {
|
|
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<string, ReadonlySet<string>>([
|
|
[
|
|
"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_<timestamp>_<random>`),
|
|
// 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.map(adoptPromptSentinel);
|
|
}
|