fix: switch channel onboarding to single-select loop
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
||||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
||||||
- Onboarding: prompt to modify/disable/delete when reconfiguring existing channel accounts and keep channel selection looping until Finished.
|
- Onboarding: switch channels setup to a single-select loop with modify/disable/delete actions per channel.
|
||||||
- TUI: show provider/model labels for the active session and default model.
|
- TUI: show provider/model labels for the active session and default model.
|
||||||
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
|
- Heartbeat: add per-agent heartbeat configuration and multi-agent docs example.
|
||||||
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
||||||
|
|||||||
@@ -219,116 +219,93 @@ export async function setupChannels(
|
|||||||
];
|
];
|
||||||
await noteChannelPrimer(prompter, primerChannels);
|
await noteChannelPrimer(prompter, primerChannels);
|
||||||
|
|
||||||
const selectionOptions = [
|
|
||||||
...installedPlugins.map((plugin) => ({
|
|
||||||
id: plugin.id as ChannelChoice,
|
|
||||||
meta: plugin.meta,
|
|
||||||
})),
|
|
||||||
...catalogEntries.map((entry) => ({
|
|
||||||
id: entry.id as ChannelChoice,
|
|
||||||
meta: entry.meta,
|
|
||||||
})),
|
|
||||||
].map((entry) => {
|
|
||||||
const meta = entry.meta;
|
|
||||||
const status = statusByChannel.get(entry.id);
|
|
||||||
return {
|
|
||||||
value: meta.id,
|
|
||||||
label: meta.selectionLabel ?? meta.label,
|
|
||||||
...(status?.selectionHint ? { hint: status.selectionHint } : {}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const quickstartDefault =
|
const quickstartDefault =
|
||||||
options?.initialSelection?.[0] ?? resolveQuickstartDefault(statusByChannel);
|
options?.initialSelection?.[0] ?? resolveQuickstartDefault(statusByChannel);
|
||||||
|
|
||||||
let selection: ChannelChoice[];
|
const shouldPromptAccountIds = options?.promptAccountIds === true;
|
||||||
if (options?.quickstartDefaults) {
|
const recordAccount = (channel: ChannelChoice, accountId: string) => {
|
||||||
const choice = (await prompter.select({
|
options?.onAccountId?.(channel, accountId);
|
||||||
message: "Select channel (QuickStart)",
|
const adapter = getChannelOnboardingAdapter(channel);
|
||||||
options: [
|
adapter?.onAccountRecorded?.(accountId, options);
|
||||||
...selectionOptions,
|
};
|
||||||
{
|
|
||||||
value: "__skip__",
|
|
||||||
label: "Skip for now",
|
|
||||||
hint: "You can add channels later via `clawdbot channels add`",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
initialValue: quickstartDefault,
|
|
||||||
})) as ChannelChoice | "__skip__";
|
|
||||||
selection = choice === "__skip__" ? [] : [choice];
|
|
||||||
} else {
|
|
||||||
const initialSelection = options?.initialSelection ?? [];
|
|
||||||
const selectionSet = new Set<ChannelChoice>(initialSelection);
|
|
||||||
const doneValue = "__done__" as const;
|
|
||||||
|
|
||||||
const buildOptions = () => [
|
const selection: ChannelChoice[] = [];
|
||||||
...selectionOptions.map((opt) => ({
|
const addSelection = (channel: ChannelChoice) => {
|
||||||
value: opt.value,
|
if (!selection.includes(channel)) selection.push(channel);
|
||||||
label: `${selectionSet.has(opt.value as ChannelChoice) ? "[x]" : "[ ]"} ${opt.label}`,
|
};
|
||||||
...(opt.hint ? { hint: opt.hint } : {}),
|
|
||||||
|
const buildSelectionOptions = (
|
||||||
|
entries: Array<{ id: ChannelChoice; meta: { id: string; label: string; selectionLabel?: string } }>,
|
||||||
|
) =>
|
||||||
|
entries.map((entry) => {
|
||||||
|
const status = statusByChannel.get(entry.id);
|
||||||
|
return {
|
||||||
|
value: entry.meta.id,
|
||||||
|
label: entry.meta.selectionLabel ?? entry.meta.label,
|
||||||
|
...(status?.selectionHint ? { hint: status.selectionHint } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const getChannelEntries = () => {
|
||||||
|
const installed = listChannelPlugins();
|
||||||
|
const installedIds = new Set(installed.map((plugin) => plugin.id));
|
||||||
|
const catalog = listChannelPluginCatalogEntries().filter(
|
||||||
|
(entry) => !installedIds.has(entry.id),
|
||||||
|
);
|
||||||
|
const entries = [
|
||||||
|
...installed.map((plugin) => ({
|
||||||
|
id: plugin.id as ChannelChoice,
|
||||||
|
meta: plugin.meta,
|
||||||
|
})),
|
||||||
|
...catalog.map((entry) => ({
|
||||||
|
id: entry.id as ChannelChoice,
|
||||||
|
meta: entry.meta,
|
||||||
})),
|
})),
|
||||||
{
|
|
||||||
value: doneValue,
|
|
||||||
label: "Finished",
|
|
||||||
hint: selectionSet.size > 0 ? "Continue with selected channels" : "Skip channels for now",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
return {
|
||||||
|
entries,
|
||||||
|
catalog,
|
||||||
|
catalogById: new Map(catalog.map((entry) => [entry.id as ChannelChoice, entry])),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
while (true) {
|
const refreshStatus = async (channel: ChannelChoice) => {
|
||||||
const choice = (await prompter.select({
|
const adapter = getChannelOnboardingAdapter(channel);
|
||||||
message: "Select channels (Enter to toggle, choose Finished to continue)",
|
if (!adapter) return;
|
||||||
options: buildOptions(),
|
const status = await adapter.getStatus({ cfg: next, options, accountOverrides });
|
||||||
})) as ChannelChoice | typeof doneValue;
|
statusByChannel.set(channel, status);
|
||||||
if (choice === doneValue) break;
|
};
|
||||||
if (selectionSet.has(choice)) {
|
|
||||||
selectionSet.delete(choice);
|
const configureChannel = async (channel: ChannelChoice) => {
|
||||||
} else {
|
const adapter = getChannelOnboardingAdapter(channel);
|
||||||
selectionSet.add(choice);
|
if (!adapter) {
|
||||||
}
|
await prompter.note(`${channel} does not support onboarding yet.`, "Channel setup");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const result = await adapter.configure({
|
||||||
selection = Array.from(selectionSet);
|
cfg: next,
|
||||||
}
|
runtime,
|
||||||
|
prompter,
|
||||||
const catalogById = new Map(catalogEntries.map((entry) => [entry.id as ChannelChoice, entry]));
|
options,
|
||||||
if (selection.some((channel) => catalogById.has(channel))) {
|
accountOverrides,
|
||||||
const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next));
|
shouldPromptAccountIds,
|
||||||
for (const channel of selection) {
|
forceAllowFrom: forceAllowFromChannels.has(channel),
|
||||||
const entry = catalogById.get(channel);
|
});
|
||||||
if (!entry) continue;
|
next = result.cfg;
|
||||||
const result = await ensureOnboardingPluginInstalled({
|
if (result.accountId) {
|
||||||
cfg: next,
|
recordAccount(channel, result.accountId);
|
||||||
entry,
|
|
||||||
prompter,
|
|
||||||
runtime,
|
|
||||||
workspaceDir,
|
|
||||||
});
|
|
||||||
next = result.cfg;
|
|
||||||
if (!result.installed) {
|
|
||||||
selection = selection.filter((id) => id !== channel);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
reloadOnboardingPluginRegistry({
|
|
||||||
cfg: next,
|
|
||||||
runtime,
|
|
||||||
workspaceDir,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedSelection: ChannelChoice[] = [];
|
|
||||||
for (const channel of selection) {
|
|
||||||
const status = statusByChannel.get(channel);
|
|
||||||
if (!status?.configured) {
|
|
||||||
updatedSelection.push(channel);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
addSelection(channel);
|
||||||
|
await refreshStatus(channel);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfiguredChannel = async (channel: ChannelChoice, label: string) => {
|
||||||
const plugin = getChannelPlugin(channel);
|
const plugin = getChannelPlugin(channel);
|
||||||
const adapter = getChannelOnboardingAdapter(channel);
|
const adapter = getChannelOnboardingAdapter(channel);
|
||||||
const label = plugin?.meta.label ?? channel;
|
const supportsDisable = Boolean(
|
||||||
const supportsDisable = Boolean(plugin?.config.setAccountEnabled || adapter?.disable);
|
options?.allowDisable && (plugin?.config.setAccountEnabled || adapter?.disable),
|
||||||
const supportsDelete = Boolean(plugin?.config.deleteAccount);
|
);
|
||||||
|
const supportsDelete = Boolean(options?.allowDisable && plugin?.config.deleteAccount);
|
||||||
const action = await promptConfiguredAction({
|
const action = await promptConfiguredAction({
|
||||||
prompter,
|
prompter,
|
||||||
label,
|
label,
|
||||||
@@ -336,21 +313,22 @@ export async function setupChannels(
|
|||||||
supportsDelete,
|
supportsDelete,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (action === "skip") {
|
if (action === "skip") return;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (action === "update") {
|
if (action === "update") {
|
||||||
updatedSelection.push(channel);
|
await configureChannel(channel);
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!options?.allowDisable) return;
|
||||||
|
|
||||||
if (action === "delete" && !supportsDelete) {
|
if (action === "delete" && !supportsDelete) {
|
||||||
await prompter.note(`${label} does not support deleting config entries.`, "Remove channel");
|
await prompter.note(`${label} does not support deleting config entries.`, "Remove channel");
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldPromptAccount =
|
const shouldPromptAccount =
|
||||||
action === "delete" ? Boolean(plugin?.config.deleteAccount) : Boolean(plugin?.config.setAccountEnabled);
|
action === "delete"
|
||||||
|
? Boolean(plugin?.config.deleteAccount)
|
||||||
|
: Boolean(plugin?.config.setAccountEnabled);
|
||||||
const accountId = shouldPromptAccount
|
const accountId = shouldPromptAccount
|
||||||
? await promptRemovalAccountId({
|
? await promptRemovalAccountId({
|
||||||
cfg: next,
|
cfg: next,
|
||||||
@@ -369,13 +347,12 @@ export async function setupChannels(
|
|||||||
message: `Delete ${label} account "${accountLabel}"?`,
|
message: `Delete ${label} account "${accountLabel}"?`,
|
||||||
initialValue: false,
|
initialValue: false,
|
||||||
});
|
});
|
||||||
if (!confirmed) {
|
if (!confirmed) return;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (plugin?.config.deleteAccount) {
|
if (plugin?.config.deleteAccount) {
|
||||||
next = plugin.config.deleteAccount({ cfg: next, accountId: resolvedAccountId });
|
next = plugin.config.deleteAccount({ cfg: next, accountId: resolvedAccountId });
|
||||||
}
|
}
|
||||||
continue;
|
await refreshStatus(channel);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (plugin?.config.setAccountEnabled) {
|
if (plugin?.config.setAccountEnabled) {
|
||||||
@@ -387,17 +364,88 @@ export async function setupChannels(
|
|||||||
} else if (adapter?.disable) {
|
} else if (adapter?.disable) {
|
||||||
next = adapter.disable(next);
|
next = adapter.disable(next);
|
||||||
}
|
}
|
||||||
}
|
await refreshStatus(channel);
|
||||||
|
};
|
||||||
|
|
||||||
selection = updatedSelection;
|
const handleChannelChoice = async (channel: ChannelChoice) => {
|
||||||
|
const { catalogById } = getChannelEntries();
|
||||||
|
const catalogEntry = catalogById.get(channel);
|
||||||
|
if (catalogEntry) {
|
||||||
|
const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next));
|
||||||
|
const result = await ensureOnboardingPluginInstalled({
|
||||||
|
cfg: next,
|
||||||
|
entry: catalogEntry,
|
||||||
|
prompter,
|
||||||
|
runtime,
|
||||||
|
workspaceDir,
|
||||||
|
});
|
||||||
|
next = result.cfg;
|
||||||
|
if (!result.installed) return;
|
||||||
|
reloadOnboardingPluginRegistry({
|
||||||
|
cfg: next,
|
||||||
|
runtime,
|
||||||
|
workspaceDir,
|
||||||
|
});
|
||||||
|
await refreshStatus(channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = getChannelPlugin(channel);
|
||||||
|
const label = plugin?.meta.label ?? catalogEntry?.meta.label ?? channel;
|
||||||
|
const status = statusByChannel.get(channel);
|
||||||
|
const configured = status?.configured ?? false;
|
||||||
|
if (configured) {
|
||||||
|
await handleConfiguredChannel(channel, label);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await configureChannel(channel);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options?.quickstartDefaults) {
|
||||||
|
const { entries } = getChannelEntries();
|
||||||
|
const choice = (await prompter.select({
|
||||||
|
message: "Select channel (QuickStart)",
|
||||||
|
options: [
|
||||||
|
...buildSelectionOptions(entries),
|
||||||
|
{
|
||||||
|
value: "__skip__",
|
||||||
|
label: "Skip for now",
|
||||||
|
hint: "You can add channels later via `clawdbot channels add`",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
initialValue: quickstartDefault,
|
||||||
|
})) as ChannelChoice | "__skip__";
|
||||||
|
if (choice !== "__skip__") {
|
||||||
|
await handleChannelChoice(choice);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const doneValue = "__done__" as const;
|
||||||
|
const initialValue = options?.initialSelection?.[0] ?? quickstartDefault;
|
||||||
|
while (true) {
|
||||||
|
const { entries } = getChannelEntries();
|
||||||
|
const choice = (await prompter.select({
|
||||||
|
message: "Select a channel",
|
||||||
|
options: [
|
||||||
|
...buildSelectionOptions(entries),
|
||||||
|
{
|
||||||
|
value: doneValue,
|
||||||
|
label: "Finished",
|
||||||
|
hint: selection.length > 0 ? "Done" : "Skip for now",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
initialValue,
|
||||||
|
})) as ChannelChoice | typeof doneValue;
|
||||||
|
if (choice === doneValue) break;
|
||||||
|
await handleChannelChoice(choice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
options?.onSelection?.(selection);
|
options?.onSelection?.(selection);
|
||||||
|
|
||||||
const selectionNotes = new Map<string, string>();
|
const selectionNotes = new Map<string, string>();
|
||||||
for (const plugin of installedPlugins) {
|
for (const plugin of listChannelPlugins()) {
|
||||||
selectionNotes.set(plugin.id, formatChannelSelectionLine(plugin.meta, formatDocsLink));
|
selectionNotes.set(plugin.id, formatChannelSelectionLine(plugin.meta, formatDocsLink));
|
||||||
}
|
}
|
||||||
for (const entry of catalogEntries) {
|
for (const entry of listChannelPluginCatalogEntries()) {
|
||||||
selectionNotes.set(entry.id, formatChannelSelectionLine(entry.meta, formatDocsLink));
|
selectionNotes.set(entry.id, formatChannelSelectionLine(entry.meta, formatDocsLink));
|
||||||
}
|
}
|
||||||
const selectedLines = selection
|
const selectedLines = selection
|
||||||
@@ -407,51 +455,9 @@ export async function setupChannels(
|
|||||||
await prompter.note(selectedLines.join("\n"), "Selected channels");
|
await prompter.note(selectedLines.join("\n"), "Selected channels");
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldPromptAccountIds = options?.promptAccountIds === true;
|
|
||||||
const recordAccount = (channel: ChannelChoice, accountId: string) => {
|
|
||||||
options?.onAccountId?.(channel, accountId);
|
|
||||||
const adapter = getChannelOnboardingAdapter(channel);
|
|
||||||
adapter?.onAccountRecorded?.(accountId, options);
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const channel of selection) {
|
|
||||||
const adapter = getChannelOnboardingAdapter(channel);
|
|
||||||
if (!adapter) continue;
|
|
||||||
const result = await adapter.configure({
|
|
||||||
cfg: next,
|
|
||||||
runtime,
|
|
||||||
prompter,
|
|
||||||
options,
|
|
||||||
accountOverrides,
|
|
||||||
shouldPromptAccountIds,
|
|
||||||
forceAllowFrom: forceAllowFromChannels.has(channel),
|
|
||||||
});
|
|
||||||
next = result.cfg;
|
|
||||||
if (result.accountId) {
|
|
||||||
recordAccount(channel, result.accountId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options?.skipDmPolicyPrompt) {
|
if (!options?.skipDmPolicyPrompt) {
|
||||||
next = await maybeConfigureDmPolicies({ cfg: next, selection, prompter });
|
next = await maybeConfigureDmPolicies({ cfg: next, selection, prompter });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options?.allowDisable) {
|
|
||||||
for (const [channelId, status] of statusByChannel) {
|
|
||||||
if (selection.includes(channelId)) continue;
|
|
||||||
if (!status.configured) continue;
|
|
||||||
const adapter = getChannelOnboardingAdapter(channelId);
|
|
||||||
if (!adapter?.disable) continue;
|
|
||||||
const meta = getChannelPlugin(channelId)?.meta;
|
|
||||||
const disable = await prompter.confirm({
|
|
||||||
message: `Disable ${meta?.label ?? channelId} channel?`,
|
|
||||||
initialValue: false,
|
|
||||||
});
|
|
||||||
if (disable) {
|
|
||||||
next = adapter.disable(next);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user