feat(setup): move URL fallback into the open-browser prompt

On GUI devices the URL was previously rendered dim inside the
instructional `note(...)` card, then `confirmThenOpen` printed
its prompt below: read the card, see the URL, then a separate
"Press Enter to open the X" prompt with no link near it. Two
visual moments for what's really one decision.

This PR pulls the URL out of the card on GUI devices and
relocates it directly under the action line of the confirm
prompt, separated only by a dim "If browser does not appear,
please visit: <url>" line:

    │
    ◆  Press Enter to open the Developer Portal
    │  If browser does not appear, please visit: …  (dim)
    │  ● Yes / ○ No
    │

Action and fallback live as one prompt block — the user sees
both at the same time, no need to scroll back up to grab the
URL if the auto-open misses.

Headless behavior is unchanged: `formatNoteLink` still emits
"Get started: <url>" inside the card on headless devices (per
#2146), and `confirmThenOpen` still no-ops on headless (per
#2145). The only thing that changed for headless is the leading
`\n` in the helper output, which acts as a visual separator from
the steps above.

Five call sites adjusted (Discord ×3, Slack ×1, Telegram ×1) to
use `.filter((line) => line !== null)` so the now-nullable
`formatNoteLink` cleanly drops out of GUI-rendered cards.
This commit is contained in:
exe.dev user
2026-04-30 11:11:43 +00:00
committed by gavrielc
parent 6863e0f63b
commit cb15e606c3
4 changed files with 28 additions and 26 deletions

View File

@@ -164,9 +164,8 @@ async function walkThroughBotCreation(): Promise<void> {
' 2. In the "Bot" tab, click "Reset Token" and copy the token', ' 2. In the "Bot" tab, click "Reset Token" and copy the token',
' 3. On the same tab, enable "Message Content Intent"', ' 3. On the same tab, enable "Message Content Intent"',
' (under Privileged Gateway Intents)', ' (under Privileged Gateway Intents)',
'',
formatNoteLink(url), formatNoteLink(url),
].join('\n'), ].filter((line): line is string => line !== null).join('\n'),
'Create a Discord bot', 'Create a Discord bot',
); );
await confirmThenOpen(url, 'Press Enter to open the Developer Portal'); await confirmThenOpen(url, 'Press Enter to open the Developer Portal');
@@ -224,9 +223,8 @@ async function walkThroughServerCreation(): Promise<void> {
' 1. In Discord, click the "+" at the bottom of the server list', ' 1. In Discord, click the "+" at the bottom of the server list',
' 2. Choose "Create My Own" → "For me and my friends"', ' 2. Choose "Create My Own" → "For me and my friends"',
' 3. Give it any name (e.g. "NanoClaw")', ' 3. Give it any name (e.g. "NanoClaw")',
'',
formatNoteLink(url), formatNoteLink(url),
].join('\n'), ].filter((line): line is string => line !== null).join('\n'),
'Create a Discord server', 'Create a Discord server',
); );
await confirmThenOpen(url, 'Press Enter to open Discord'); await confirmThenOpen(url, 'Press Enter to open Discord');
@@ -446,9 +444,8 @@ async function promptInviteBot(
'', '',
' 1. Pick any server you\'re in (a personal one is fine)', ' 1. Pick any server you\'re in (a personal one is fine)',
' 2. Click "Authorize"', ' 2. Click "Authorize"',
'',
formatNoteLink(url), formatNoteLink(url),
].join('\n'), ].filter((line): line is string => line !== null).join('\n'),
'Add bot to a server', 'Add bot to a server',
); );
await confirmThenOpen(url, 'Press Enter to open the invite page'); await confirmThenOpen(url, 'Press Enter to open the invite page');

View File

@@ -135,9 +135,8 @@ async function walkThroughAppCreation(): Promise<void> {
' slash commands and messages from the messages tab"', ' slash commands and messages from the messages tab"',
' 4. Basic Information → copy the "Signing Secret"', ' 4. Basic Information → copy the "Signing Secret"',
' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)', ' 5. Install to Workspace → copy the "Bot User OAuth Token" (xoxb-…)',
'',
formatNoteLink(SLACK_APPS_URL), formatNoteLink(SLACK_APPS_URL),
].join('\n'), ].filter((line): line is string => line !== null).join('\n'),
'Create a Slack app', 'Create a Slack app',
); );
await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings'); await confirmThenOpen(SLACK_APPS_URL, 'Press Enter to open Slack app settings');

View File

@@ -50,9 +50,8 @@ export async function runTelegramChannel(displayName: string): Promise<void> {
note( note(
[ [
`Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`, `Opening @${botUsername} in Telegram so it's ready when the pairing code shows up.`,
'',
formatNoteLink(botUrl), formatNoteLink(botUrl),
].join('\n'), ].filter((line): line is string => line !== null).join('\n'),
'Open Telegram', 'Open Telegram',
); );
await confirmThenOpen(botUrl, 'Press Enter to open Telegram'); await confirmThenOpen(botUrl, 'Press Enter to open Telegram');

View File

@@ -40,35 +40,42 @@ export function openUrl(url: string): void {
} }
/** /**
* Format a URL for display inside a setup `note(...)` card. On * Format a URL for inclusion in a setup `note(...)` card. On
* GUI devices the URL renders dim — it's a fallback in case the * headless devices we surface the URL inside the card with a
* auto-open misses, and `confirmThenOpen` is doing the heavy * "Get started:" label at full strength — copy-pasting onto
* lifting of getting the user there. On headless devices the * another device is the actual action, not an incidental
* URL becomes the user's only path forward, so we surface it * reference. The leading `\n` acts as a visual separator from
* with a "Get started:" label and full-strength text — copy- * the body steps above; callers `.filter(line => line !== null)`
* pasting onto another device is the actual action, not an * before joining, so on GUI we drop the line entirely (and the
* incidental reference. * URL ends up below the next-step confirm prompt as a "if
* browser does not appear, please visit" fallback — see
* `confirmThenOpen`).
*/ */
export function formatNoteLink(url: string): string { export function formatNoteLink(url: string): string | null {
if (isHeadless()) return `Get started: ${url}`; if (isHeadless()) return `\nGet started: ${url}`;
return k.dim(url); return null;
} }
/** /**
* Gate a browser-open on a confirm so the user is ready for their browser * Gate a browser-open on a confirm so the user is ready for their browser
* to take focus. Proceeds on cancel as well — the user can always copy the * to take focus. Proceeds on cancel as well. On headless devices both the
* URL from the note that precedes the prompt. On headless devices both * prompt and the open are skipped — the URL is already surfaced inside
* the prompt and the open are skipped — there's no browser to time * the surrounding note (via `formatNoteLink`).
* focus for, and the URL is already visible in the surrounding note. *
* On GUI devices the confirm message includes the fallback URL on the
* lines below the action ("If browser does not appear, please visit:
* <url>" in dim) so the user has a copy-paste path right next to the
* action button without needing to scroll back up to the card.
*/ */
export async function confirmThenOpen( export async function confirmThenOpen(
url: string, url: string,
message = 'Press Enter to open your browser', message = 'Press Enter to open your browser',
): Promise<void> { ): Promise<void> {
if (isHeadless()) return; if (isHeadless()) return;
const fallback = `\n${k.dim(`If browser does not appear, please visit: ${url}`)}`;
ensureAnswer( ensureAnswer(
await p.confirm({ await p.confirm({
message, message: `${message}${fallback}`,
initialValue: true, initialValue: true,
}), }),
); );