Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 42 additions & 6 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import "#bootstrap/environment";
import { join } from "node:path";

import {
ambiguousPluginServerNames,
applyStepCodeConfigDefaults,
buildStepSystemPromptAppendix,
configureHttpDispatcher,
Expand Down Expand Up @@ -50,6 +51,7 @@ import {
resolveStepAgentDir,
resolveStepConfigDir,
resolveStepConfigRoot,
resolveStepMcpServer,
resolveStepStorageRoot,
restoreStdout,
runFeedbackCommand,
Expand Down Expand Up @@ -427,18 +429,52 @@ try {
}
} else if (subcommand === "login" || subcommand === "logout") {
const name = args[1];
const server = name ? servers[name] : undefined;
if (!name || !server) {
// A plugin's servers are named `<pluginId>__<serverName>`, which is the
// name a start-failure message prints and the name `/mcp` shows. Reading
// only config.toml here meant those servers could not be logged into at
// all, even by copying the name out of the error.
//
// Keep the *resolved* name, not the user's input: credentials are stored
// under `<name>|<url>` and the runtime reads them under the published
// name, so logging in with a bare `context7` would write a key that the
// `context7__context7` connection never looks up — reporting success and
// then failing the same way on the next start.
const resolved = name && !servers[name] ? await resolveStepMcpServer(name) : undefined;
const serverName = resolved?.name ?? name;
const server = name ? (servers[name] ?? resolved?.declaration) : undefined;
// A bare name shared by several plugins resolves to nothing. Say so
// instead of claiming no such server exists, which sends the user
// hunting for a typo that is not there.
const ambiguous = name && !servers[name] && !resolved ? await ambiguousPluginServerNames(name) : [];
if (!name) {
process.stderr.write("Usage: step mcp login|logout <http-server-name>\n");
process.exitCode = 1;
} else if (!server) {
if (ambiguous.length > 0) {
process.stderr.write(
`Several plugins declare a server named '${name}': ${ambiguous.join(", ")}. ` +
"Use the qualified name.\n",
);
} else {
// Name the sources so a typo is distinguishable from a server
// that exists only in a plugin, and point at /mcp for the latter.
process.stderr.write(
`No MCP server named '${name}'. It is not in config.toml` +
(servers && Object.keys(servers).length > 0
? ` (which has: ${Object.keys(servers).join(", ")})`
: "") +
", and no plugin declares it. Run /mcp inside Step to see the names in use.\n",
);
}
process.exitCode = 1;
} else if (!server.url) {
process.stderr.write(
`"${name}" doesn't support OAuth login — it's only available for HTTP and SSE servers.\n`,
);
process.exitCode = 1;
} else if (subcommand === "login") {
try {
await loginMcpServer(name, server.url, server.oauth, process.env);
await loginMcpServer(serverName!, server.url, server.oauth, process.env);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("403") && name.toLowerCase().includes("figma")) {
Expand All @@ -450,9 +486,9 @@ try {
}
} else {
process.stdout.write(
logoutMcpServer(name, server.url, process.env)
? `Logged out of MCP server '${name}'.\n`
: `No stored credentials for MCP server '${name}'.\n`,
logoutMcpServer(serverName!, server.url, process.env)
? `Logged out of MCP server '${serverName}'.\n`
: `No stored credentials for MCP server '${serverName}'.\n`,
);
}
} else {
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/ui/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2922,6 +2922,7 @@ export class InteractiveMode {
timeout: opts?.timeout,
onToggleToolsExpanded: () => this.toggleToolOutputExpansion(),
presentation: this.presentation,
searchable: opts?.searchable,
},
);
this.extensionSelector = selector;
Expand Down Expand Up @@ -3003,6 +3004,7 @@ export class InteractiveMode {
tui: this.ui,
timeout: opts?.timeout,
presentation: this.presentation,
examples: opts?.examples,
});
this.extensionInput = input;
unmount = this.mountExtensionDialog(input, opts);
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/ui/startup-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ export async function showStartupInput(
title: string,
placeholder?: string,
paths?: StartupTuiPathOptions,
examples?: readonly string[],
): Promise<string | undefined> {
const ui = await createStartupTui(settingsManager, paths);
return new Promise((resolve) => {
Expand All @@ -225,6 +226,7 @@ export async function showStartupInput(
{
tui: ui,
presentation: STARTUP_PRESENTATION,
examples,
},
);
ui.addChild(input);
Expand Down
27 changes: 26 additions & 1 deletion apps/cli/src/ui/view/dialogs/extension-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,25 @@
*/

import { DynamicBorder, keyHint, theme } from "@step-harness/coding-agent";
import { Container, type Focusable, getKeybindings, Input, Spacer, Text, type TUI } from "@step-harness/pi-tui";
import {
Container,
type Focusable,
getKeybindings,
Input,
Spacer,
Text,
type TUI,
truncateToWidth,
} from "@step-harness/pi-tui";
import { CountdownTimer } from "./countdown-timer.ts";
import { renderStepDialogFrame, splitStepDialogTitle } from "./step-dialog.ts";

export interface ExtensionInputOptions {
tui?: TUI;
timeout?: number;
presentation?: "native" | "step";
/** Reference lines listed under the title while the input is still empty. */
examples?: readonly string[];
}

export class ExtensionInputComponent extends Container implements Focusable {
Expand All @@ -22,6 +33,7 @@ export class ExtensionInputComponent extends Container implements Focusable {
private currentTitle: string;
private countdown: CountdownTimer | undefined;
private readonly placeholder: string | undefined;
private readonly examples: readonly string[];
private readonly presentation: "native" | "step";

// Focusable implementation - propagate to input for IME cursor positioning
Expand All @@ -48,6 +60,7 @@ export class ExtensionInputComponent extends Container implements Focusable {
this.baseTitle = title;
this.currentTitle = title;
this.placeholder = placeholder;
this.examples = opts?.examples ?? [];
this.presentation = opts?.presentation ?? "native";

this.addChild(new DynamicBorder());
Expand Down Expand Up @@ -90,6 +103,17 @@ export class ExtensionInputComponent extends Container implements Focusable {
}
}

/** The examples block, listed under the title above the editable row. */
private renderExamples(contentWidth: number): string[] {
if (this.examples.length === 0) return [];
const rows = [theme.fg("muted", "Examples:")];
for (const example of this.examples) {
rows.push(theme.fg("dim", truncateToWidth(` · ${example}`, contentWidth, "…", false)));
}
rows.push("");
return rows;
}

dispose(): void {
this.countdown?.dispose();
}
Expand All @@ -105,6 +129,7 @@ export class ExtensionInputComponent extends Container implements Focusable {
if (heading.length > 0) rows.push(theme.fg("accent", theme.bold(`● ${heading}`)));
for (const line of body) rows.push(theme.fg("muted", line));
if (rows.length > 0) rows.push("");
for (const row of this.renderExamples(contentWidth)) rows.push(row);

let inputLine = this.input.render(contentWidth)[0] ?? "> ";
if (this.input.getValue().length === 0 && this.placeholder) {
Expand Down
77 changes: 70 additions & 7 deletions apps/cli/src/ui/view/dialogs/extension-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
import { DynamicBorder, keyHint, rawKeyHint, theme } from "@step-harness/coding-agent";
import {
Container,
fuzzyFilter,
getKeybindings,
Input,
type SelectItem,
SelectList,
Spacer,
Expand All @@ -22,10 +24,18 @@ export interface ExtensionSelectorOptions {
timeout?: number;
onToggleToolsExpanded?: () => void;
presentation?: "native" | "step";
/**
* Show a search row above the list and filter it as the user types.
*
* Use it for lists whose length is set by the environment rather than by the
* dialog — a marketplace can carry hundreds of plugins, and scrolling is not
* a way to find one. Movement keys still drive the list; everything else goes
* to the search row, so a query never has to be prefixed with a mode key.
*/
searchable?: boolean;
}

export class ExtensionSelectorComponent extends Container {
private readonly options: string[];
private selectedIndex = 0;
private readonly selectList: SelectList;
private onSelectCallback: (option: string) => void;
Expand All @@ -36,6 +46,8 @@ export class ExtensionSelectorComponent extends Container {
private countdown: CountdownTimer | undefined;
private onToggleToolsExpanded: (() => void) | undefined;
private readonly presentation: "native" | "step";
private readonly searchInput: Input | undefined;
private readonly allItems: SelectItem[];

constructor(
title: string,
Expand All @@ -46,7 +58,6 @@ export class ExtensionSelectorComponent extends Container {
) {
super();

this.options = options;
this.onSelectCallback = onSelect;
this.onCancelCallback = onCancel;
this.onToggleToolsExpanded = opts?.onToggleToolsExpanded;
Expand All @@ -58,6 +69,7 @@ export class ExtensionSelectorComponent extends Container {
value: option,
label: option,
}));
this.allItems = items;
this.selectList = new SelectList(items, Math.max(1, Math.min(8, items.length)), {
selectedPrefix: (text) => theme.fg("accent", text),
selectedText: (text) => theme.fg("accent", text),
Expand All @@ -68,7 +80,7 @@ export class ExtensionSelectorComponent extends Container {
this.selectList.onSelect = (item) => this.onSelectCallback(item.value);
this.selectList.onCancel = () => this.onCancelCallback();
this.selectList.onSelectionChange = (item) => {
const index = items.indexOf(item);
const index = this.visibleItems().indexOf(item);
if (index >= 0) this.selectedIndex = index;
};

Expand All @@ -79,6 +91,15 @@ export class ExtensionSelectorComponent extends Container {
this.addChild(this.titleText);
this.addChild(new Spacer(1));

if (opts?.searchable) {
this.searchInput = new Input();
// Enter in the search row means "take the highlighted plugin", the same
// as Enter anywhere else here; the list owns the actual selection.
this.searchInput.onSubmit = () => this.selectList.handleInput("\r");
this.addChild(this.searchInput);
this.addChild(new Spacer(1));
}

if (opts?.timeout && opts.timeout > 0 && opts.tui) {
this.countdown = new CountdownTimer(
opts.timeout,
Expand Down Expand Up @@ -108,25 +129,61 @@ export class ExtensionSelectorComponent extends Container {
this.addChild(new DynamicBorder());
}

/** The items the list is currently showing, which the search row re-filters. */
private visibleItems(): SelectItem[] {
const query = this.searchInput?.getValue() ?? "";
return query.trim() ? fuzzyFilter(this.allItems, query, (item) => item.value) : this.allItems;
}

handleInput(keyData: string): void {
const kb = getKeybindings();
if (kb.matches(keyData, "app.tools.expand")) {
this.onToggleToolsExpanded?.();
return;
}

const searchInput = this.searchInput;
const drivesList =
kb.matches(keyData, "tui.select.up") ||
kb.matches(keyData, "tui.select.down") ||
kb.matches(keyData, "tui.select.pageUp") ||
kb.matches(keyData, "tui.select.pageDown") ||
kb.matches(keyData, "tui.select.confirm") ||
kb.matches(keyData, "tui.select.cancel");

if (searchInput && !drivesList) {
searchInput.handleInput(keyData);
this.applySearch();
return;
}

// Pi's SelectList owns regular movement/confirm/cancel. Step only keeps
// the product's non-circular boundary behavior for transient decisions.
if (this.presentation === "step") {
// the product's non-circular boundary behavior for transient decisions, and
// a search row owns the movement keys instead once one is present.
if (this.presentation === "step" && !searchInput) {
const atFirst = this.selectedIndex === 0;
const atLast = this.selectedIndex === Math.max(0, this.options.length - 1);
const atLast = this.selectedIndex === Math.max(0, this.allItems.length - 1);
if ((kb.matches(keyData, "tui.select.up") && atFirst) || (kb.matches(keyData, "tui.select.down") && atLast)) {
return;
}
}
this.selectList.handleInput(keyData);
}

/**
* Push the current query onto the list.
*
* The selector drives the list through setItems rather than setFilter, so an
* empty match set has to be preserved deliberately: setItems with the full
* list would resurrect every option the query just excluded.
*/
private applySearch(): void {
const query = this.searchInput?.getValue() ?? "";
this.selectList.setItems(this.visibleItems());
if (query.trim() && this.visibleItems().length === 0) this.selectList.setEmpty();
this.selectedIndex = 0;
}

override render(width: number): string[] {
if (this.presentation !== "step") return super.render(width);

Expand All @@ -138,6 +195,11 @@ export class ExtensionSelectorComponent extends Container {
if (heading.length > 0) rows.push(theme.fg("accent", theme.bold(`● ${heading}`)));
for (const line of body) rows.push(theme.fg("muted", line));
if (rows.length > 0) rows.push("");
if (this.searchInput) {
rows.push(theme.fg("muted", "Search:"));
rows.push(...this.searchInput.render(contentWidth));
rows.push("");
}
rows.push(...this.selectList.render(contentWidth));
rows.push("");
rows.push(
Expand All @@ -148,7 +210,8 @@ export class ExtensionSelectorComponent extends Container {
" " +
keyHint("tui.select.confirm", "select") +
" " +
keyHint("tui.select.cancel", "cancel"),
keyHint("tui.select.cancel", "cancel") +
(this.searchInput ? " type to search" : ""),
),
contentWidth,
),
Expand Down
Loading
Loading