diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 504cdcc86..163a26920 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,19 +1,24 @@ # Changelog ## [Unreleased] - ### Added +- Added a `Web search` setup tab that lets users choose the preferred `providers.webSearch` provider during onboarding +- Added manual authorization-code/redirect URL prompts for OAuth providers that require non-callback login in the setup wizard - Added an `omp completions ` command that prints a shell completion script generated from the live command/flag metadata, so completions never drift from the actual CLI. Subcommands, flags, and enum values complete statically; `--model`/`--smol`/`--slow`/`--plan` resolve against the bundled model catalog and `--resume` against on-disk sessions via a hidden `__complete` helper. - Added a `/switch` slash command that opens the temporary model selector for the current session, mirroring the `alt+p` keybinding. +### Changed + +- Changed setup onboarding to a tabbed `Set up your providers` scene with dedicated `Sign in` and `Web search` panels +- Changed the glyph mode picker to preselect the currently configured symbol preset instead of always defaulting to Unicode and to show live glyph samples in the picker rows +- Changed OAuth sign-in flow in the setup wizard so users can authenticate multiple providers before leaving with Escape + ### Fixed +- Fixed OAuth login handling to cancel cleanly when users press Esc or Ctrl+C during authentication - Fixed the `read` tool description advertising `inspect_image` ("for visual analysis, call `inspect_image`") even when the `inspect_image` tool was disabled, which left the model hunting for a tool absent from its function list. The image section is now gated on `inspect_image.enabled`: when disabled it instead states that reading an image path returns the decoded image inline. - Fixed session-title generation latching onto literal text inside fenced code blocks — a pasted UI mockup containing "Welcome to Claude Code v2.1.158" titled the session "Setup Screen for Claude Code v2.1.158" instead of capturing the actual request. The first user message now has fenced code blocks stripped before titling (both the online `pi/smol` and local CPU model paths share the same preprocessing), with a fallback to the original message when stripping would leave too little to title from (e.g. a message that is essentially just a code block). - -### Fixed - - Fixed slash-command autocomplete repaint requests so Windows Terminal sessions with unknown native viewport state keep updating the input box and candidate list. ([#1550](https://github.com/can1357/oh-my-pi/issues/1550)) ## [15.6.0] - 2026-05-30 diff --git a/packages/coding-agent/src/modes/setup-wizard/index.ts b/packages/coding-agent/src/modes/setup-wizard/index.ts index bde9088e9..29da5d873 100644 --- a/packages/coding-agent/src/modes/setup-wizard/index.ts +++ b/packages/coding-agent/src/modes/setup-wizard/index.ts @@ -1,7 +1,7 @@ import type { Settings } from "../../config/settings"; import type { InteractiveModeContext } from "../types"; import { glyphSetupScene } from "./scenes/glyph"; -import { providerSetupScene } from "./scenes/provider"; +import { providersSetupScene } from "./scenes/providers"; import { themeSetupScene } from "./scenes/theme"; import type { SetupScene } from "./scenes/types"; import { SetupWizardComponent } from "./wizard-overlay"; @@ -9,7 +9,7 @@ import { SetupWizardComponent } from "./wizard-overlay"; export type { SetupScene, SetupSceneController, SetupSceneHost, SetupSceneResult } from "./scenes/types"; export const ALL_SCENES = [ - providerSetupScene, + providersSetupScene, glyphSetupScene, themeSetupScene, ] as const satisfies readonly SetupScene[]; diff --git a/packages/coding-agent/src/modes/setup-wizard/scenes/glyph.ts b/packages/coding-agent/src/modes/setup-wizard/scenes/glyph.ts index 0c034cf21..1a728bfa0 100644 --- a/packages/coding-agent/src/modes/setup-wizard/scenes/glyph.ts +++ b/packages/coding-agent/src/modes/setup-wizard/scenes/glyph.ts @@ -2,20 +2,27 @@ import { type SelectItem, SelectList } from "@oh-my-pi/pi-tui"; import { getSelectListTheme, type SymbolPreset, setSymbolPreset, theme } from "../../theme/theme"; import type { SetupScene, SetupSceneController, SetupSceneHost } from "./types"; -const GLYPH_PRESETS: readonly SymbolPreset[] = ["unicode", "nerd", "ascii"]; +const GLYPH_PRESETS = ["nerd", "unicode", "ascii"] as const satisfies readonly SymbolPreset[]; -const GLYPH_ITEMS: readonly SelectItem[] = [ - { value: "unicode", label: "Unicode", description: "Standard terminal glyphs" }, - { value: "nerd", label: "Nerd Font", description: "Powerline and devicons; requires Nerd Font" }, - { value: "ascii", label: "ASCII", description: "Maximum compatibility" }, -]; +const GLYPH_LABELS: Readonly> = { + nerd: "Nerd Font", + unicode: "Unicode", + ascii: "ASCII", +}; const GLYPH_SAMPLES: Readonly> = { nerd: "   󰉋 ", - unicode: "✔ 📁 ⬢ ╭╮ ├─", - ascii: "[ok] > + [D] |--", + unicode: "✔ ✖ 📁 ⬢ ╭─╮ ├─ • ⠋ →", + ascii: "[ok] [x] > + [D] +-+ |-- * ->", }; +/** One picker row per preset; the description column shows live sample glyphs instead of prose. */ +const GLYPH_ITEMS: readonly SelectItem[] = GLYPH_PRESETS.map((preset, index) => ({ + value: preset, + label: `${index + 1} ${GLYPH_LABELS[preset]}`, + description: preset === "nerd" ? `${GLYPH_SAMPLES.nerd} ╭─╮ ├─ ◆ ✔ ✖` : GLYPH_SAMPLES[preset], +})); + class GlyphSceneController implements SetupSceneController { title = "Choose glyph mode"; subtitle = "Pick the row that renders cleanly in your terminal."; @@ -25,7 +32,9 @@ class GlyphSceneController implements SetupSceneController { constructor(private readonly host: SetupSceneHost) { this.#selectList = new SelectList(GLYPH_ITEMS, GLYPH_ITEMS.length, getSelectListTheme()); - this.#selectList.setSelectedIndex(GLYPH_PRESETS.indexOf("unicode")); + const current = theme.getSymbolPreset(); + const currentIndex = GLYPH_PRESETS.indexOf(current); + this.#selectList.setSelectedIndex(currentIndex >= 0 ? currentIndex : 0); this.#selectList.onSelectionChange = item => { this.#preview(item.value as SymbolPreset); }; @@ -53,12 +62,7 @@ class GlyphSceneController implements SetupSceneController { render(width: number): string[] { return [ - theme.fg("muted", "If a row shows boxes, tofu, or misaligned icons, choose another one."), - "", - ...GLYPH_PRESETS.map((preset, index) => { - const label = `${index + 1}. ${preset === "nerd" ? "Nerd Font" : preset === "unicode" ? "Unicode" : "ASCII"}`; - return `${theme.bold(label.padEnd(14))} ${GLYPH_SAMPLES[preset]}`; - }), + theme.fg("muted", "If a row shows boxes, tofu, or misaligned icons, pick another."), "", ...this.#selectList.render(width), ]; diff --git a/packages/coding-agent/src/modes/setup-wizard/scenes/providers.ts b/packages/coding-agent/src/modes/setup-wizard/scenes/providers.ts new file mode 100644 index 000000000..2a387e91a --- /dev/null +++ b/packages/coding-agent/src/modes/setup-wizard/scenes/providers.ts @@ -0,0 +1,69 @@ +import { TabBar } from "@oh-my-pi/pi-tui"; +import { getTabBarTheme } from "../../shared"; +import { SignInTab } from "./sign-in"; +import type { SetupScene, SetupSceneController, SetupSceneHost, SetupTab } from "./types"; +import { WebSearchTab } from "./web-search"; + +/** + * Tabbed "Set up your providers" scene. Composes independent panels (model + * sign-in, web search) behind a {@link TabBar}; the active panel owns + * rendering and input, while modal panels (e.g. an in-flight OAuth login) + * temporarily suppress tab switching. + */ +class ProvidersSceneController implements SetupSceneController { + title = "Set up your providers"; + subtitle = "Sign in and pick a web search provider. Press Esc when you're done."; + + #tabs: SetupTab[]; + #tabBar: TabBar; + + constructor(host: SetupSceneHost) { + this.#tabs = [new SignInTab(host), new WebSearchTab(host)]; + this.#tabBar = new TabBar( + "Providers", + this.#tabs.map(tab => ({ id: tab.id, label: tab.label })), + getTabBarTheme(), + ); + this.#tabBar.onTabChange = () => { + this.#activeTab().onActivate?.(); + host.requestRender(); + }; + } + + #activeTab(): SetupTab { + return this.#tabs[this.#tabBar.getActiveIndex()] ?? this.#tabs[0]; + } + + onMount(): void { + this.#activeTab().onActivate?.(); + } + + invalidate(): void { + for (const tab of this.#tabs) tab.invalidate(); + } + + handleInput(data: string): void { + const tab = this.#activeTab(); + if (tab.modal) { + tab.handleInput(data); + return; + } + if (this.#tabBar.handleInput(data)) return; + tab.handleInput(data); + } + + render(width: number): string[] { + return [...this.#tabBar.render(width), "", ...this.#activeTab().render(width)]; + } + + dispose(): void { + for (const tab of this.#tabs) tab.dispose(); + } +} + +export const providersSetupScene: SetupScene = { + id: "providers", + title: "Set up your providers", + minVersion: 1, + mount: host => new ProvidersSceneController(host), +}; diff --git a/packages/coding-agent/src/modes/setup-wizard/scenes/provider.ts b/packages/coding-agent/src/modes/setup-wizard/scenes/sign-in.ts similarity index 67% rename from packages/coding-agent/src/modes/setup-wizard/scenes/provider.ts rename to packages/coding-agent/src/modes/setup-wizard/scenes/sign-in.ts index 3c8120bca..b854f85cd 100644 --- a/packages/coding-agent/src/modes/setup-wizard/scenes/provider.ts +++ b/packages/coding-agent/src/modes/setup-wizard/scenes/sign-in.ts @@ -1,10 +1,12 @@ +import type { AuthStorage } from "@oh-my-pi/pi-ai"; import type { OAuthProvider } from "@oh-my-pi/pi-ai/utils/oauth/types"; import { Input, matchesKey, truncateToWidth } from "@oh-my-pi/pi-tui"; import { getAgentDbPath } from "@oh-my-pi/pi-utils"; import { OAuthSelectorComponent } from "../../components/oauth-selector"; import { theme } from "../../theme/theme"; -import type { SetupScene, SetupSceneController, SetupSceneHost } from "./types"; +import type { SetupSceneHost, SetupTab } from "./types"; +/** Providers whose OAuth flow needs a pasted code/redirect URL rather than a callback server. */ const CALLBACK_SERVER_PROVIDERS: Partial> = { anthropic: true, "openai-codex": true, @@ -19,9 +21,16 @@ interface PromptState { input: Input; } -class ProviderSceneController implements SetupSceneController { - title = "Choose a provider"; - subtitle = "Log in now, or skip and use /login later."; +/** + * "Sign in" panel: lets the user authenticate one or more model providers via + * OAuth. Unlike a standalone scene it never auto-advances the wizard — the user + * may sign in to several providers and then continue with Esc. + */ +export class SignInTab implements SetupTab { + readonly id = "sign-in"; + readonly label = "Sign in"; + + #authStorage: AuthStorage; #selector: OAuthSelectorComponent; #statusLines: string[] = []; #prompt: PromptState | undefined; @@ -31,16 +40,13 @@ class ProviderSceneController implements SetupSceneController { #disposed = false; constructor(private readonly host: SetupSceneHost) { - const authStorage = host.ctx.session.modelRegistry.authStorage; - this.#selector = new OAuthSelectorComponent( - "login", - authStorage, - providerId => { - void this.#login(providerId); - }, - () => host.finish("skipped"), - { requestRender: () => host.requestRender() }, - ); + this.#authStorage = host.ctx.session.modelRegistry.authStorage; + this.#selector = this.#createSelector(); + } + + /** Modal while an OAuth flow is running so the scene won't switch tabs or finish. */ + get modal(): boolean { + return this.#loggingInProvider !== undefined; } dispose(): void { @@ -59,7 +65,6 @@ class ProviderSceneController implements SetupSceneController { if (this.#loggingInProvider) { if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.#loginAbort?.abort(); - this.host.finish("skipped"); } return; } @@ -67,13 +72,9 @@ class ProviderSceneController implements SetupSceneController { } render(width: number): string[] { - const lines = [ - theme.fg("muted", "Pick the provider you want to use for your first chat."), - theme.fg("dim", "Already configured? Press Esc to skip this step."), - "", - ]; + const lines = [theme.fg("muted", "Pick a provider to sign in — you can connect more than one."), ""]; if (this.#loggingInProvider) { - lines.push(theme.bold(`Logging in to ${this.#loggingInProvider}`), ""); + lines.push(theme.bold(`Signing in to ${this.#loggingInProvider}`), ""); } else { lines.push(...this.#selector.render(width)); } @@ -90,6 +91,18 @@ class ProviderSceneController implements SetupSceneController { return lines; } + #createSelector(): OAuthSelectorComponent { + return new OAuthSelectorComponent( + "login", + this.#authStorage, + providerId => { + void this.#login(providerId); + }, + () => this.host.finish("skipped"), + { requestRender: () => this.host.requestRender() }, + ); + } + async #login(providerId: string): Promise { if (this.#loggingInProvider || this.#disposed) return; const useManualInput = CALLBACK_SERVER_PROVIDERS[providerId as OAuthProvider] === true; @@ -100,7 +113,7 @@ class ProviderSceneController implements SetupSceneController { this.host.restoreFocus(); this.host.requestRender(); try { - await this.host.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, { + await this.#authStorage.login(providerId as OAuthProvider, { signal: this.#loginAbort.signal, onAuth: info => { this.#statusLines.push(theme.fg("accent", `Open this URL: ${info.url}`)); @@ -122,16 +135,28 @@ class ProviderSceneController implements SetupSceneController { this.#showPrompt({ message: "Paste the authorization code (or full redirect URL):" }), }); await this.host.ctx.session.modelRegistry.refresh(); - this.#statusLines.push(theme.fg("success", `${theme.status.success} Logged in to ${providerId}`)); - this.#statusLines.push(theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`)); + if (this.#disposed) return; + this.#statusLines = [ + theme.fg("success", `${theme.status.success} Signed in to ${providerId}`), + theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`), + ]; + this.#loggingInProvider = undefined; + this.#loginAbort = undefined; + this.#selector.stopValidation(); + this.#selector = this.#createSelector(); + this.host.restoreFocus(); this.host.requestRender(); - await Bun.sleep(500); - if (!this.#disposed) this.host.finish("done"); } catch (error) { if (this.#disposed) return; - const message = error instanceof Error ? error.message : String(error); - this.#statusLines.push(theme.fg("error", `Login failed: ${message}`)); - this.#statusLines.push(theme.fg("dim", "Choose another provider or press Esc to skip.")); + if (this.#loginAbort?.signal.aborted) { + this.#statusLines = [theme.fg("dim", "Login cancelled.")]; + } else { + const message = error instanceof Error ? error.message : String(error); + this.#statusLines = [ + theme.fg("error", `Login failed: ${message}`), + theme.fg("dim", "Choose another provider or press Esc to continue."), + ]; + } this.#loggingInProvider = undefined; this.#loginAbort = undefined; this.host.restoreFocus(); @@ -166,10 +191,3 @@ class ProviderSceneController implements SetupSceneController { this.host.requestRender(); } } - -export const providerSetupScene: SetupScene = { - id: "provider-login", - title: "Choose a provider", - minVersion: 1, - mount: host => new ProviderSceneController(host), -}; diff --git a/packages/coding-agent/src/modes/setup-wizard/scenes/types.ts b/packages/coding-agent/src/modes/setup-wizard/scenes/types.ts index 8b4ca9a60..97633287a 100644 --- a/packages/coding-agent/src/modes/setup-wizard/scenes/types.ts +++ b/packages/coding-agent/src/modes/setup-wizard/scenes/types.ts @@ -19,6 +19,26 @@ export interface SetupSceneController extends Component { dispose?(): void; } +/** + * A single panel inside a tabbed setup scene. The host scene owns the tab bar + * and forwards rendering/input to the active tab. + */ +export interface SetupTab { + readonly id: string; + readonly label: string; + /** + * While `true` the tab owns all keyboard input (e.g. an in-progress OAuth + * login). The parent scene MUST NOT switch tabs or finish while modal. + */ + readonly modal: boolean; + render(width: number): string[]; + handleInput(data: string): void; + invalidate(): void; + /** Called when the tab becomes active (including initial mount). */ + onActivate?(): void; + dispose(): void; +} + export interface SetupScene { id: string; title: string; diff --git a/packages/coding-agent/src/modes/setup-wizard/scenes/web-search.ts b/packages/coding-agent/src/modes/setup-wizard/scenes/web-search.ts new file mode 100644 index 000000000..906803bfe --- /dev/null +++ b/packages/coding-agent/src/modes/setup-wizard/scenes/web-search.ts @@ -0,0 +1,128 @@ +import { type SelectItem, SelectList, truncateToWidth } from "@oh-my-pi/pi-tui"; +import { SETTINGS_SCHEMA } from "../../../config/settings-schema"; +import { getSearchProvider, setPreferredSearchProvider } from "../../../web/search/provider"; +import { isSearchProviderPreference, type SearchProviderId } from "../../../web/search/types"; +import { getSelectListTheme, theme } from "../../theme/theme"; +import type { SetupSceneHost, SetupTab } from "./types"; + +const MAX_VISIBLE = 8; + +/** Reuse the settings schema as the single source of truth for labels/descriptions. */ +const WEB_SEARCH_ITEMS: readonly SelectItem[] = SETTINGS_SCHEMA["providers.webSearch"].ui.options.map(option => ({ + value: option.value, + label: option.label, + description: option.description, +})); + +type Availability = "checking" | boolean; + +/** + * "Web search" panel: picks the provider the web_search tool should prefer and + * reports whether the highlighted provider is ready to use given current + * credentials (env keys or OAuth sign-ins from the Sign in tab). + */ +export class WebSearchTab implements SetupTab { + readonly id = "web-search"; + readonly label = "Web search"; + readonly modal = false; + + #list: SelectList; + #availability = new Map(); + #status: string[] = []; + #disposed = false; + + constructor(private readonly host: SetupSceneHost) { + this.#list = new SelectList(WEB_SEARCH_ITEMS, MAX_VISIBLE, getSelectListTheme()); + const current = host.ctx.settings.get("providers.webSearch"); + const index = WEB_SEARCH_ITEMS.findIndex(item => item.value === current); + if (index >= 0) this.#list.setSelectedIndex(index); + this.#list.onSelectionChange = item => this.#onHighlight(item.value); + this.#list.onSelect = item => this.#apply(item.value); + this.#list.onCancel = () => host.finish("skipped"); + } + + onActivate(): void { + // Auth may have changed in the Sign in tab; re-check from scratch. + this.#availability.clear(); + this.#status = []; + const selected = this.#list.getSelectedItem(); + if (selected) this.#onHighlight(selected.value); + this.host.requestRender(); + } + + handleInput(data: string): void { + this.#list.handleInput(data); + } + + invalidate(): void { + this.#list.invalidate(); + } + + dispose(): void { + this.#disposed = true; + } + + render(width: number): string[] { + const lines = [ + theme.fg("muted", "Choose the provider the web_search tool should prefer."), + "", + ...this.#list.render(width), + ]; + const selected = this.#list.getSelectedItem(); + if (selected) { + lines.push("", ...this.#readinessLines(selected.value).map(line => truncateToWidth(line, width))); + } + if (this.#status.length > 0) { + lines.push("", ...this.#status.map(line => truncateToWidth(line, width))); + } + return lines; + } + + #onHighlight(value: string): void { + this.#status = []; + if (value !== "auto") this.#checkAvailability(value as SearchProviderId); + this.host.requestRender(); + } + + #checkAvailability(id: SearchProviderId): void { + if (this.#availability.has(id)) return; + this.#availability.set(id, "checking"); + void (async () => { + let ready = false; + try { + const provider = await getSearchProvider(id); + ready = await provider.isAvailable(this.host.ctx.session.modelRegistry.authStorage); + } catch { + ready = false; + } + if (this.#disposed) return; + this.#availability.set(id, ready); + this.host.requestRender(); + })(); + } + + #apply(value: string): void { + if (!isSearchProviderPreference(value)) return; + this.host.ctx.settings.set("providers.webSearch", value); + setPreferredSearchProvider(value); + const label = WEB_SEARCH_ITEMS.find(item => item.value === value)?.label ?? value; + this.#status = [theme.fg("success", `${theme.status.success} Web search set to ${label}`)]; + if (value !== "auto" && this.#availability.get(value as SearchProviderId) === false) { + this.#status.push(theme.fg("dim", "Not configured yet — add its API key or sign in to enable it.")); + } + this.host.requestRender(); + } + + #readinessLines(value: string): string[] { + if (value === "auto") { + return [theme.fg("dim", "Automatically uses the first configured provider.")]; + } + const state = this.#availability.get(value as SearchProviderId); + if (state === undefined || state === "checking") { + return [theme.fg("dim", "Checking availability…")]; + } + return state + ? [theme.fg("success", `${theme.status.success} Ready to use`)] + : [theme.fg("warning", `${theme.status.pending} Needs credentials`)]; + } +} diff --git a/packages/coding-agent/test/setup-wizard.test.ts b/packages/coding-agent/test/setup-wizard.test.ts index 0b1fbbc4c..da492f52c 100644 --- a/packages/coding-agent/test/setup-wizard.test.ts +++ b/packages/coding-agent/test/setup-wizard.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, it } from "bun:test"; import { runOnboardingSetup } from "../src/commands/setup"; import { Settings } from "../src/config/settings"; +import { SETTINGS_SCHEMA } from "../src/config/settings-schema"; +import { WebSearchTab } from "../src/modes/setup-wizard/scenes/web-search"; import { ALL_SCENES, CURRENT_SETUP_VERSION, @@ -67,9 +69,9 @@ describe("setup wizard scene selection", () => { expect(await selectSetupScenes(0, ALL_SCENES, ctx, { isTTY: true, setupWizardEnabled: false })).toEqual([]); }); - it("keeps the provider scene eligible even when a model is already configured", async () => { + it("keeps the providers scene eligible even when a model is already configured", async () => { const scenes = await selectSetupScenes(0, ALL_SCENES, fakeContextWithConfiguredModel(), { isTTY: true }); - expect(scenes.some(scene => scene.id === "provider-login")).toBe(true); + expect(scenes.some(scene => scene.id === "providers")).toBe(true); }); it("force mode ignores version and user skip gates but still requires a TTY", async () => {