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
40 changes: 40 additions & 0 deletions packages/cli/src/__tests__/cmd-interactive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,44 @@ const {
isCancel: (value: unknown) => isCancelValues.has(value),
});

// The interactive picker now reads from /dev/tty (picker.js) instead of Clack.
// Mock the input functions, but GATE interception with `interceptPicker` so this
// process-global mock only affects THESE tests — other files (e.g. picker-cov,
// which asserts the real implementation) delegate to the real picker.
const realPicker: typeof import("../picker.js") = await import("../picker.js");
// Capture the REAL function objects now — Bun re-points the namespace bindings to
// the mock, so we must hold concrete refs to delegate to (else infinite recursion).
const realFns = {
pickToTTY: realPicker.pickToTTY,
pickToTTYWithActions: realPicker.pickToTTYWithActions,
multiPickToTTY: realPicker.multiPickToTTY,
readLineFromTTY: realPicker.readLineFromTTY,
readHiddenLineFromTTY: realPicker.readHiddenLineFromTTY,
pickFallback: realPicker.pickFallback,
parsePickerInput: realPicker.parsePickerInput,
};
let interceptPicker = false;
mock.module("../picker.js", () => ({
...realFns,
pickToTTY: mock((config: Parameters<typeof realFns.pickToTTY>[0]) => {
if (!interceptPicker) {
return realFns.pickToTTY(config);
}
const value = selectReturnValues[selectCallIndex] ?? "claude";
selectCallIndex++;
return isCancelValues.has(value) ? null : value;
}),
multiPickToTTY: mock((config: Parameters<typeof realFns.multiPickToTTY>[0]) =>
interceptPicker ? ([] as string[]) : realFns.multiPickToTTY(config),
),
readLineFromTTY: mock(() =>
interceptPicker ? { ttyUnavailable: false, cancelled: false, value: "" } : realFns.readLineFromTTY(),
),
readHiddenLineFromTTY: mock(() =>
interceptPicker ? { ttyUnavailable: false, cancelled: false, value: "" } : realFns.readHiddenLineFromTTY(),
),
}));

// Import commands after mock setup
const { cmdInteractive } = await import("../commands/index.js");

Expand Down Expand Up @@ -78,6 +116,7 @@ describe("cmdInteractive", () => {
mockSpinnerMessage.mockClear();

// Reset per-test mutable state
interceptPicker = true;
selectCallIndex = 0;
selectReturnValues = [];
isCancelValues = new Set();
Expand All @@ -94,6 +133,7 @@ describe("cmdInteractive", () => {
});

afterEach(() => {
interceptPicker = false;
global.fetch = originalFetch;
processExitSpy.mockRestore();
restoreMocks(consoleMocks.log, consoleMocks.error);
Expand Down
66 changes: 22 additions & 44 deletions packages/cli/src/commands/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import { asyncTryCatch, tryCatch, unwrapOr } from "../shared/result.js";
import { maybeShowStarPrompt } from "../shared/star-prompt.js";
import { AGENTSEA_CLI } from "../shared/cli-invocation.js";
import { captureEvent, setTelemetryContext } from "../shared/telemetry.js";
import { defaultAgentseaLabel, logError, validateModelId, CLACK_LOG_OPTS } from "../shared/ui.js";
import { defaultAgentseaLabel, logError, promptText, validateModelId, CLACK_LOG_OPTS } from "../shared/ui.js";
import { multiPickToTTY, pickToTTY } from "../picker.js";
import { cmdLink } from "./link.js";
import { activeServerPicker } from "./list.js";
import { execScript, showDryRunPreview } from "./run.js";
Expand All @@ -32,12 +33,14 @@ import {
async function selectAgent(manifest: Manifest): Promise<string> {
const agents = agentKeys(manifest);
const agentHints = buildAgentPickerHints(manifest);
const agentChoice = await p.select({
// /dev/tty (pickToTTY) instead of Clack's process.stdin reader — works in
// curl|bash / remote contexts where process.stdin can't receive input.
const agentChoice = pickToTTY({
message: "Select an agent",
options: mapToSelectOptions(agents, manifest.agents, agentHints),
initialValue: agents.includes("openclaw") ? "openclaw" : agents[0],
defaultValue: agents.includes("openclaw") ? "openclaw" : agents[0],
});
if (p.isCancel(agentChoice)) {
if (agentChoice === null) {
handleCancel();
}
return agentChoice;
Expand Down Expand Up @@ -114,12 +117,12 @@ async function selectCloud(
hint: "bring your own server via IP address",
});

const cloudChoice = await p.select({
const cloudChoice = pickToTTY({
message: "Select a cloud",
options,
initialValue: cloudList[0],
defaultValue: cloudList[0],
});
if (p.isCancel(cloudChoice)) {
if (cloudChoice === null) {
handleCancel();
}

Expand All @@ -145,23 +148,10 @@ async function promptAgentseaName(agentSlug: string): Promise<string | undefined
}

const defaultName = defaultAgentseaLabel(agentSlug);
const agentseaName = await p.text({
message: "Name your agentsea",
placeholder: defaultName,
const agentseaName = await promptText("Name your agentsea", {
defaultValue: defaultName,
validate: (value) => {
if (!value) {
return undefined;
}
if (value.length > 128) {
return "Name must be 128 characters or less";
}
return undefined;
},
validate: (value) => (value.length > 128 ? "Name must be 128 characters or less" : undefined),
});
if (p.isCancel(agentseaName)) {
handleCancel();
}
return agentseaName || undefined;
}

Expand Down Expand Up @@ -230,40 +220,27 @@ async function promptSetupOptions(agentName: string): Promise<Set<string> | unde

const defaultOnValues = filteredSteps.filter((s) => s.defaultOn).map((s) => s.value);

const selected = await p.multiselect({
// /dev/tty multiselect — works in curl|bash / remote where stdin can't be read.
const selected = multiPickToTTY({
message: "Setup options (↑/↓ navigate, space=toggle, a=all, enter=confirm)",
options: filteredSteps.map((s) => ({
value: s.value,
label: s.label,
hint: s.hint,
})),
initialValues: defaultOnValues.length > 0 ? defaultOnValues : undefined,
required: false,
initialValues: defaultOnValues,
});

if (p.isCancel(selected)) {
return new Set<string>();
}

const stepSet = new Set(selected);

// If user selected "Custom model", prompt for the model ID and set MODEL_ID env
if (stepSet.has("custom-model")) {
stepSet.delete("custom-model");
const modelId = await p.text({
message: "Model ID",
placeholder: "provider/model-name",
validate: (val) => {
if (!val?.trim()) {
return "Model ID is required";
}
if (!validateModelId(val.trim())) {
return "Invalid format — use provider/model";
}
return undefined;
},
const modelId = await promptText("Model ID", {
validate: (val) =>
!val.trim() ? "Model ID is required" : !validateModelId(val.trim()) ? "Invalid format — use provider/model" : undefined,
});
if (!p.isCancel(modelId) && modelId.trim()) {
if (modelId.trim()) {
process.env.MODEL_ID = modelId.trim();
}
}
Expand Down Expand Up @@ -310,7 +287,7 @@ export async function cmdInteractive(): Promise<void> {
captureEvent("menu_shown", {
active_servers: activeServers.length,
});
const topChoice = await p.select({
const topChoice = pickToTTY({
message: "What would you like to do?",
options: [
{
Expand All @@ -322,8 +299,9 @@ export async function cmdInteractive(): Promise<void> {
label: "Connect to existing server",
},
],
defaultValue: "create",
});
if (p.isCancel(topChoice)) {
if (topChoice === null) {
captureEvent("menu_cancelled");
handleCancel();
}
Expand Down
101 changes: 101 additions & 0 deletions packages/cli/src/picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,107 @@ export function readLineFromTTY(): TTYLineResult {
return readLineViaTTY({ echo: true });
}

interface MultiPickConfig {
message: string;
options: PickOption[];
initialValues?: string[];
}

/**
* Multi-select read directly from /dev/tty (space toggles, ↑/↓ move, a=all, Enter
* confirms, Ctrl-C/Esc cancel). Like pickToTTY, this works when process.stdin can't
* be read (curl|bash / remote). Returns the selected values; [] on cancel; the
* initial values when non-interactive or /dev/tty is unavailable (never blocks).
*/
export function multiPickToTTY(config: MultiPickConfig): string[] {
if (process.env.AGENTSEA_NON_INTERACTIVE === "1" || config.options.length === 0) {
return config.initialValues ?? [];
}

const selected = new Set(config.initialValues ?? []);
let cursor = 0;
let maxW = 80;
let height = 0;
let render: (w: WriteFn, first: boolean) => void = () => {};

return withTTYKeyLoop<string[]>({
fallback: () => config.initialValues ?? [],
cancel: () => [],
init: (w, cols) => {
maxW = cols - 1;
height = 1 + config.options.length + 1; // title + options + footer
render = (wr, first) => {
if (!first) {
wr(A.up(height) + A.col1 + A.clearBelow);
}
wr(`${A.bold}${A.cyan}? ${trunc(config.message, maxW - 2)}${A.reset}\r\n`);
for (let i = 0; i < config.options.length; i++) {
const o = config.options[i];
const mark = selected.has(o.value) ? "\u25c9" : "\u25ef";
const pointer = i === cursor ? `${A.green}>${A.reset}` : " ";
const label = trunc(o.label, maxW - 6);
wr(`${pointer} ${mark} ${i === cursor ? A.bold : ""}${label}${A.reset}`);
if (o.hint && i === cursor) {
const remaining = maxW - 6 - label.length - 2;
if (remaining > 3) {
wr(` ${A.dim}${trunc(o.hint, remaining)}${A.reset}`);
}
}
wr("\r\n");
}
wr(`${A.dim} \u2191/\u2193 move space toggle a all \u23ce confirm Ctrl-C cancel${A.reset}\r\n`);
};
render(w, true);
},
handleKey: (key, w) => {
switch (key) {
case "\r":
case "\n": {
w(A.up(height) + A.col1 + A.clearBelow);
const labels = config.options.filter((o) => selected.has(o.value)).map((o) => o.label);
w(`${A.green}${A.bold}? ${config.message}:${A.reset} ${A.cyan}${labels.join(", ") || "(none)"}${A.reset}\r\n`);
return { done: true, result: [...selected] };
}
case " ": {
const v = config.options[cursor].value;
if (selected.has(v)) {
selected.delete(v);
} else {
selected.add(v);
}
render(w, false);
return { done: false };
}
case "a": {
if (selected.size === config.options.length) {
selected.clear();
} else {
for (const o of config.options) {
selected.add(o.value);
}
}
render(w, false);
return { done: false };
}
case "\x1b[A":
case "\x1bOA":
case "k":
cursor = (cursor - 1 + config.options.length) % config.options.length;
render(w, false);
return { done: false };
case "\x1b[B":
case "\x1bOB":
case "j":
cursor = (cursor + 1) % config.options.length;
render(w, false);
return { done: false };
default:
return { done: false };
}
},
});
}

// ── fallback picker ───────────────────────────────────────────────────────────

/**
Expand Down
79 changes: 58 additions & 21 deletions packages/cli/src/shared/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,57 @@ export async function prompt(question: string): Promise<string> {
return (r.data || "").trim();
}

/**
* Prompt for a line of text with an optional default and validation, reading from
* /dev/tty (works in curl|bash / remote where process.stdin can't be read). Re-asks
* on validation failure; Ctrl-C exits. Falls back to Clack when /dev/tty is
* unavailable; returns the default when non-interactive.
*/
export async function promptText(
message: string,
opts?: { defaultValue?: string; validate?: (v: string) => string | undefined },
): Promise<string> {
if (process.env.AGENTSEA_NON_INTERACTIVE === "1") {
return opts?.defaultValue ?? "";
}
for (;;) {
resetStderrAttributes();
const hint = opts?.defaultValue ? ` (${opts.defaultValue})` : "";
process.stderr.write(`${message}${hint}: `);
const tty = readLineFromTTY();
if (tty.ttyUnavailable) {
const validate = opts?.validate;
const r = await p.text({
message,
placeholder: opts?.defaultValue,
defaultValue: opts?.defaultValue,
validate: validate ? (v) => validate(v ?? "") : undefined,
});
if (p.isCancel(r)) {
process.stderr.write("\n");
process.exit(0);
}
return (((r as string) || opts?.defaultValue) ?? "").trim();
}
if (tty.cancelled) {
process.stderr.write("\n");
process.exit(0);
}
let value = tty.value.trim();
if (!value && opts?.defaultValue) {
value = opts.defaultValue;
}
if (opts?.validate) {
const err = opts.validate(value);
if (err) {
logWarn(err);
continue;
}
}
return value;
}
}

/**
* Display an interactive select from pipe-delimited items.
* Items format: "id|label" per line.
Expand Down Expand Up @@ -944,27 +995,13 @@ export async function promptGridCatalogModelId(
return choice;
}

for (;;) {
const typed = await p.text({
message: "Model ID (from The Grid catalogue)",
placeholder: "provider/model-name",
validate: (val) => {
if (!val?.trim()) {
return "Model ID is required";
}
if (!validateModelId(val.trim())) {
return "Invalid format — use provider/model";
}
return undefined;
},
});
if (p.isCancel(typed)) {
return undefined;
}
if (typed.trim() && validateModelId(typed.trim())) {
return typed.trim();
}
}
// /dev/tty input (promptText loops until valid); Clack's stdin reader can't
// receive input in curl|bash / remote contexts.
const typed = await promptText("Model ID (from The Grid catalogue)", {
validate: (val) =>
!val.trim() ? "Model ID is required" : !validateModelId(val.trim()) ? "Invalid format — use provider/model" : undefined,
});
return typed.trim() || undefined;
}

/** Convert display name to kebab-case. */
Expand Down
Loading