Files
paseo/packages/protocol/src/terminal-profiles.ts
T
paseo-bot[bot]andMohamed Boudra a43c8d888f Show the Cursor icon for terminal profiles that run cursor-agent (#5379)
* Show the Cursor icon for terminal profiles that run cursor-agent

The Cursor CLI binary is cursor-agent, so a profile created in settings
with that command matched no known icon and fell back to the generic
terminal icon. Map cursor-agent to the cursor icon while keeping
command matching exact.

Closes #4702

* Verify Cursor terminal profile icons in real browser

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-09-28 16:48:20 +01:00

324 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) };
}
// CLIs whose binary is not named after their icon. Matching stays exact, so a
// command only gets an icon when its whole base name is listed.
const COMMAND_ICON_ALIASES: ReadonlyArray<[string, string]> = [["cursor-agent", "cursor"]];
const WELL_KNOWN_COMMAND_ICONS = new Map([
...KNOWN_PROVIDER_ICON_NAMES.map((name): [string, string] => [name, name]),
...COMMAND_ICON_ALIASES,
]);
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);
}