refactor(settings): move guided setup logic out of components and share its UI (#921)

* feat(storybook): prototype guided AI setup and task assignments

* refactor(storybook): adopt shared control foundations

* refactor(storybook): build AI setup on current settings foundations

Move the prototype to settings/ai-setup and compose SettingsSection, SettingsGroup, SettingsRow, AppAlert, AppBadge, and AppCheckbox instead of local section, status, and badge markup. Replace the nonexistent danger color and raw amber with the error and warning tokens.

* feat(ui): add a shared radio group

AppRadioGroup wraps the Reka radio group with typed options, labels each radio by its option text with any description as its accessible description, and supports all arrow keys unless an orientation is set. The AI setup wizard uses it for the spending choice, named by the step heading, and shares the choice card style with its checkboxes.

* fix(ui): draw unchecked checkboxes on the field background

AppCheckbox filled its box with the surface (text) color, so unchecked boxes were nearly black in the light theme and nearly white in the dark theme. Use the panel field background and accent hover border shared with the radio group and switch.

* refactor(storybook): drop the simplified AI connections panel

AI setup has two modes: skippable guided onboarding for most people and the existing advanced settings for power users, both editing the same model settings. Remove the third, simplified connections and tasks panel. The wizard's Advanced settings action and the returning-user screen now stand in for ModelsPanel, which offers Run guided setup. Removing Gateway's own Back button also fixes the blank screen it led to.

* feat(ai): plan guided AI setup from the model catalog

planOnboarding proposes design and vision models from the access a person already has, using the real provider and agent catalog, and falls back to OpenRouter only when pay-as-you-go is allowed. applyOnboardingPlan merges a confirmed plan into the model settings, reusing matching connections and profiles, keeping roles it was not asked about, and dropping only the empty fresh-install profile.

* refactor(ai): share model provider display names

Move the provider, agent, and Pi display-name lookup out of the model settings workflow so guided setup can reuse it.

* feat(ai): offer guided AI setup over the real model settings

Guided setup asks what AI should help with, what access the person
already has, and whether pay-as-you-go models are allowed, then
proposes design and vision models from the provider and agent catalog.
Connections reuse the provider key field and connection test, keys are
saved through the credential manager, and the confirmed plan is merged
into the model settings, keeping anything configured by hand. Saving
reports saved, partial, or failed like the profile editor.

A fresh install whose model settings are still the empty placeholder is
offered setup once with a skippable welcome; existing setups never see
it. Settings → AI & agents can run it again, and Advanced settings hands
off to the model editor. The Storybook fixtures for agents, OpenRouter
sign-in, Vercel AI Gateway, and the local server are replaced by the
real flow, with all copy translated.

Browser tests start with the offer dismissed through the shared
Playwright storage state; the first-run spec clears it.

* fix(ai): keep configured models and credentials safe in guided setup

Running guided setup again planned from the catalog defaults, so it
replaced hand-configured design and vision models and dropped their
settings; it now keeps a configured model while its access is still
selected or onboarding cannot offer that provider, and keeps vision when
nothing new covers it. Reused profiles must have the capabilities the
plan relies on, and an explicit vision assignment without image input is
cleared.

A server's saved key and its status now apply only to the connection at
the address being entered, and its connection test uses that
connection's API type. Entered keys are copied before saving, so closing
setup mid-save no longer drops them, and the Models list refreshes key
status after setup saves a key. Servers that do not check keys get a
hint to enter any value, and a step that only keeps configured models
says so instead of showing nothing.

* test(ai): check key status right after guided setup

The Models list must show a key saved by guided setup as connected without reopening Settings.

* feat(ai): sign in to OpenRouter from guided setup

OpenRouter can now be connected with its OAuth PKCE flow instead of a
pasted key. In the browser, sign-in opens in a popup that returns to a
static callback page on the app's origin, which relays the redirect to
the editor over a BroadcastChannel, so the editor never navigates away.
The desktop app opens the system browser and receives the redirect on
a one-shot 127.0.0.1 listener, the localhost callback OpenRouter
documents. Either way the editor checks the state, exchanges the code
for a key directly with OpenRouter, fills it in, and runs the
connection test. Waiting, blocked pop-ups, cancellation, expiry, and
failures are reported in the step, which keeps the pasted-key path.

Setup no longer offers Clear for a saved key, since removing keys
belongs to the advanced settings, and the service worker leaves
/oauth/ pages to the network.

* fix(settings): report unreadable model keys as unavailable

A saved key the browser credential store could not read, for example one left from an older session on the same origin, rejected the model status refresh and the startup credential check, which surfaced as a global error toast. Each read failure now marks only that connection as unavailable.

* feat(ai): map every role in guided setup and verify OpenRouter sign-in

Guided setup now proposes a model for design, vision, review, and fast
work, and the review step is a map of those roles with every suitable
model from the connected providers and a "Use recommended setup"
shortcut. Fast work defaults to the provider's catalog model tagged as
fast; behind an agent, review and fast work use the API model chosen
for vision. Review and fast work are never asked about, so a configured
choice, including none, stays unless it follows a design model it can
no longer follow.

The pay-as-you-go question only appears when the access already
selected leaves a requested role without a model, so choosing
OpenRouter or another account no longer asks it.

After signing in with OpenRouter, setup checks the key with
OpenRouter's key endpoint, which costs no credits, instead of a text
generation test. The step then says it is signed in, names the key,
warns when the account has no credits yet, and offers another account
in place of the key field and test button.

* feat(ai): offer OpenRouter only for goals nothing selected covers

The separate pay-as-you-go step asked an abstract question even when
the selected access already covered every goal. The connect step now
names a goal nothing selected can cover, such as visual review behind
a coding agent or a local server, and offers to add OpenRouter for it;
once added, it says OpenRouter fills the gap and can be removed again.
Setup can finish without visual review, but not without a design model.

A local or company server can be marked as able to read images, which
lets it cover visual review and makes it the preferred vision model
over a paid account.

* feat(ai): show provider logos and more providers in guided setup

Guided setup shows monochrome logos for coding agents, API accounts,
and local servers, from LobeHub's MIT-licensed static SVG set loaded as
an `ai` icon collection, so they follow the theme like Lucide icons.
DeepSeek, Z.ai, and MiniMax are offered under "More providers", and a
local server can start from the Ollama or LM Studio address instead of
typing it.

* feat(ai): guide coding agent setup in guided setup

Choosing Claude Code, Codex, or Gemini CLI in the desktop app now checks
whether the agent's ACP program and OpenPencil's MCP server, which
agents use to reach the canvas, are installed. An allowlisted
agent_lookup command finds the program on the same widened PATH as the
MCP lookup. The card shows install commands only for what is missing,
checks again on request, links a new setup guide, and copies a prompt
that asks an agent the person already uses to install both, confirm
they are on PATH, and sign in. In the browser, the agent section links
to the desktop app.

* fix(ai): space the More providers toggle like a group heading

The toggle sat flush against the account cards above and below it; it now reads as a group heading with the same rhythm as the other sections.

* feat(ai): detect and install coding agents in guided setup

Adopt the local agent discovery from #847. A desktop agent_lookup
command reports each agent's own CLI, its ACP adapter, npm, and
OpenPencil's MCP server on the widened PATH without starting any of
them, and the app can install a missing adapter or the MCP server with
npm, limited by the shell capability to those exact packages and the
MCP version that matches the app. Guided setup now tells "installed but
the OpenPencil adapter is missing" apart from "not installed", offers
one-click installs, links each vendor's own setup guide, and keeps the
manual commands and setup prompt for the browser, missing npm, or a
failed install. Codex install instructions move to
@agentclientprotocol/codex-acp, which replaces @zed-industries/codex-acp.

Kiro CLI support from the same pull request is left for a separate
change, since it needs ACP transport work.

Co-authored-by: GitttHomie <134371845+GitttHomie@users.noreply.github.com>

* build(app): resolve LobeHub icons with import.meta.resolve

The architecture lint forbids createRequire in ESM build code.

* test(app): seed AI setup specs through storageState

Follows the storage seeding used by other browser specs and the import type rule.

* feat(ai): set up Pi with its own sign-ins in guided setup

Pi now runs with the providers signed in to in the Pi CLI and Pi's
default model. The Harness companion reuses ~/.pi/agent; the app reads
only Pi's settings.json for the default model and never auth.json. A
saved key is still used as an AI Gateway key.

Guided setup offers Pi next to the other coding agents. On the desktop
it checks the Harness companion, the MCP server, and Pi's default
model, and installs the companion with one click through npm.

Agent discovery now reads the installed versions of the MCP server and
the Harness companion from their package.json without starting them.
Setup flags a version that does not match the app and shows the update
command for the package manager that installed it, instead of
reporting the server as installed and failing at the first message.

* feat(ai): check agent companions before a chat starts

A Pi chat without the Harness companion, or any agent chat whose
companion or MCP server version does not match the app, failed when the
process started and showed only the generic request error. The chat now
checks the companions through agent discovery first and names the fix,
with an action that opens guided setup. Pi sign-in and model problems
use the same path.

The Pi model editor shows the same companion, MCP server, and default
model status as guided setup, and no longer requires a model ID, since
Pi falls back to the default model set in Pi.

Supersedes the companion detection in #566, which ran the companion to
read its version and required an exact version match.

* fix(harness): start Pi sessions with MCP tools and keep unsent messages

Pi chats in the desktop app always configure OpenPencil's MCP server,
and three companion problems stopped them:

- @ai-sdk/harness-pi imports pi-mcp-adapter, which publishes TypeScript
  sources. Node refuses to strip types under node_modules, so the
  companion now strips them through a module load hook limited to
  TypeScript dependencies. Bun runs them as is.
- pi-mcp-adapter imports @earendil-works/pi-tui, declared only as an
  optional peer, so npm left it out. The companion depends on it at the
  version pi-coding-agent uses.
- Pi reports live-process resume, yet the service handed it state saved
  by an earlier session, and the just-bash sandbox cannot resume, so
  every later session with that ID failed. Live-process backends now
  start fresh and drop saved state.

When a chat cannot start, the composer now keeps the typed message
instead of discarding it.

* fix(harness): keep companion stdout for protocol messages

Pi prepares the packages listed in a person's Pi settings with npm,
which inherits the companion's stdout, and libraries log through
console.log. Both landed in the JSONL protocol stream, where the app
discarded them with warnings. The companion now keeps the real stdout
for protocol messages, sends other stdout writes to stderr, and quiets
npm on success through its environment.

The type-stripping hook no longer prints Node's experimental warning,
and the app logs companion stderr as diagnostics rather than errors,
since failures arrive as protocol errors.

Document Pi in the coding agents guide, the AI chat page, and the
README: guided setup installs the companion, Pi uses the Pi CLI's
sign-ins and default model, an AI Gateway key is optional, and the
companion needs Node.js 22.15 or later.

* test(harness): keep the pi-tui pin in step with pi-coding-agent

The companion depends on pi-tui only because pi-mcp-adapter imports it while declaring it optional (nicobailon/pi-mcp-adapter#805). Upgrading @ai-sdk/harness-pi moves pi-coding-agent, and a pin left behind would make npm install a second, mismatched pi-tui. The test fails until the pin matches.

* test(ai): follow the model catalog in guided setup tests

The plan and apply tests repeated catalog default and fast model IDs, so master's model update broke them without any change in setup behavior. They now read those models from the catalog.

* test(ai): keep the model catalog helper with the shared test helpers

Unit test homes under tests/app accept only *.test.ts files, so the onboarding tests' catalog helper moves to tests/helpers/ai.

* docs: tighten the guided setup and Pi changelog entries

Name every provider and server preset guided setup offers, describe the role step as it now works, and shorten the Pi entry.

* test(ai): type the guided setup test stubs for the test typecheck

Master now typechecks the test suites: fetch fakes go through fetchStub, the chat ref is shallow like the real one, and mocks declare the arguments the tests inspect.

* test: type the tabs module in the closed-documents spec

The spec imported the tabs module by its served URL without a type, which fails the test type check on master.

* test(ai): assert outcomes instead of copy in guided setup tests

Drop the setup-prompt test, which checked prompt prose, the onboarding wrapper cases that restated discovery, and the coversGoals case. Story plays and the OpenRouter E2E flow now assert controls and saved models instead of sentences and catalog model names, and the fast-model helper checks the planned model's catalog entry instead of recomputing the choice. tests/AGENTS.md states the rule.

* feat(ai): return desktop OpenRouter sign-in through a deep link

The desktop app ran a hand-written HTTP server on a localhost port to receive OpenRouter's redirect, and OpenRouter labels apps with a localhost callback by host and port. OpenRouter now redirects to a page on the web app that opens openpencil://oauth/openrouter with the same query, and the desktop shell forwards that link to the webview as an oauth-callback event. The attempt that started sign-in checks the state and exchanges the code with its PKCE verifier, which never leaves the app.

* feat(ai): ask OpenPencil's companions for their version

The desktop app read a companion's version by following its executable's symlink up to a package.json. That only worked for the Unix npm and bun layouts: Windows .cmd and .exe shims and version-manager shims such as Volta and mise are not links into the package, so the version was always unknown and an outdated companion went unreported. The MCP server, stdio bridge, and Harness companion now print their version for --version, and the app runs each installed one with --version --help under a timeout. A release older than --version prints its help or exits without a version line, which reads as outdated. A bun global install on Windows now gets the bun update command too.

* refactor(settings): move agent setup out of guided setup

Model settings show Pi's setup too, so the agent and Pi panels, their install row, and the setup state move from guided setup to settings/agents and app/ai/agents/setup (useAgentSetup), with a theme of their own. The connection test button, used only by settings, moves from chat to settings/provider.

* refactor(settings): decide agent setup actions outside the panels

The agent and Pi panels each worked out which companion to install or update, which commands to show by hand, and which problem to warn about, with nearly the same error mapping twice. agentSetupView and piSetupView now decide that from the setup state, with unit tests, and the panels only pick the words. Discovery reports when a lookup has finished, which replaces the Pi panel's watcher that kept results visible during a rescan.

* feat(ui): add a copy field for commands and addresses

The agent and Pi setup panels and the MCP settings panel each rendered code next to a Copy button and tracked which value had just been copied. AppCopyField does that with a command look for terminal lines and a plain look for addresses in a settings row.

* feat(ui): add a checkbox card and drop the unused radio group

Guided setup wrapped a checkbox in a bordered label and named it with an aria-label that repeated the visible text, while its description was not announced. AppCheckboxCard names the checkbox by its label and describes it by its text, and AppCheckbox accepts aria-labelledby and aria-describedby for that. AppRadioGroup lost its only user when spending became an OpenRouter offer, so it goes until a radio choice needs it.

* refactor(settings): split OpenRouter sign-in and server fields out of guided setup's connection

The connection step switched between an agent, Pi, OpenRouter sign-in, a server's presets and fields, and the key fields in one component. Sign-in and the server fields are components of their own, and serverPresetFor matches an address to its preset next to the presets.

* refactor(settings): share model role labels

Model settings and guided setup each mapped a role to its name and explanation. useModelRoleLabels does it once for both.

* refactor(settings): keep setup workflow state out of components

Guided setup's flow assigned connection edits and goal order itself, and the model profile editor wired Pi's readiness check by hand. The workflow now owns updateConnection and setGoal, and usePiSetup checks Pi each time the editor switches to it.

* refactor(settings): list model roles in one order everywhere

Guided setup listed design, vision, review, fast while model settings listed design, review, fast, vision. Both now follow AI_MODEL_ROLES.

* fix(ai): ask for a Pi sign-in when Pi has none

readPiAccount returned an account whenever a home folder existed, so a chat with no Pi sign-in reached the Harness and failed with a provider error instead of the guided pi-sign-in fix. It now reports whether Pi's auth.json exists, without reading it, and the capability allows that one check.

* fix(ai): keep the attachments of a message that was not sent

A message that never reached the chat came back to the composer as text only: its image previews were revoked and its referenced layers dropped. The composer now takes back the whole submission, or releases the previews when newer text replaced it. A message counts as sent once the chat holds it, so a failure after that no longer hands it back to be sent twice.

* refactor(app): read the app version from one constant

Four modules each derived the app version from the build define with the same test fallback.

* refactor(ai): report chat submission errors from their own module

Reverting turns from master and keeping unsent drafts together took useChatSubmission past the composition-root limit. The test for reverted turns now passes the setup messages the submission reports.

* refactor(app): keep the app version with the runtime config

Tools typecheck src/constants.ts through app imports without the Vite defines, so the version constant moves to src/app/runtime/version.ts.

* test(desktop): check npm installs of the companions at the app's release version

The scope test named the companion packages and version literally, so it would keep passing if the app requested something else. It now builds them from the app's package names and the release version a build embeds.

* refactor(ai): parse OpenRouter, Pi, and sign-in callback data with Valibot

The OpenRouter key info and code exchange checked their JSON with typeof chains, Pi's settings parsed JSON in a try before validating it, and the desktop sign-in trusted the shell's callback payload as typed. Each now goes through one schema.

* fix(ai): take back a message whose images could not be prepared

A message with images appears in the chat before its images are prepared, so a preparation failure counted as sent: the draft did not come back and its previews were already revoked. A message now counts as sent once it is dispatched; a failure before that removes the shown message and hands the draft back, and the composer's previews are revoked only after dispatch.

* fix(ai): restore an unsent message only in its own conversation

Switching conversations while a message was being sent could restore it into the newly opened one. The draft now comes back only if the conversation is unchanged, and its previews are released otherwise.

* refactor(app): keep one app version constant

The update window added an APP_VERSION to src/constants.ts beside the one in src/app/runtime/version.ts. The tools typecheck reaches src/constants.ts without the Vite defines, so the update window now reads the runtime one.

---------

Co-authored-by: GitttHomie <134371845+GitttHomie@users.noreply.github.com>
This commit is contained in:
Danila Poyarkov 2026-10-07 09:28:25 +00:00 committed by GitHub
parent 8bf71bd92f
commit 9b4100b1d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1277 additions and 939 deletions

View file

@ -46,6 +46,15 @@ export function detectedAgents(lookup: AgentLookup): DetectedAgent[] {
})
}
/** What went wrong in the last lookup or installation. */
export type DiscoveryError =
| 'lookup'
| 'install'
| 'npm'
| 'canvas-install'
| 'canvas-start'
| 'harness-install'
export function createAgentDiscovery(options: {
enabled: boolean
lookup: () => Promise<AgentLookup>
@ -57,15 +66,15 @@ export function createAgentDiscovery(options: {
const snapshot = shallowRef<AgentLookup | null>(null)
const scanning = ref(false)
const installing = ref<ACPAgentID | 'canvas' | 'harness' | null>(null)
const error = ref<
'lookup' | 'install' | 'npm' | 'canvas-install' | 'canvas-start' | 'harness-install' | null
>(null)
const error = ref<DiscoveryError | null>(null)
let pending: Promise<void> | null = null
const agents = computed(() => (snapshot.value ? detectedAgents(snapshot.value) : []))
const availableAgents = computed(() =>
agents.value.filter((agent) => agent.status === 'available')
)
const npmAvailable = computed(() => Boolean(snapshot.value?.executables.npm))
/** A lookup has finished, so a later rescan can keep showing its results. */
const checked = computed(() => snapshot.value !== null)
// Checks read through functions so they see the snapshot of a rescan that just finished.
function hasBridge(): boolean {
return Boolean(snapshot.value?.executables[MCP_EXECUTABLE])
@ -191,6 +200,7 @@ export function createAgentDiscovery(options: {
agents,
availableAgents,
scanning,
checked,
installing,
error,
npmAvailable,

269
src/app/ai/agents/setup.ts Normal file
View file

@ -0,0 +1,269 @@
import { computed, ref, toValue, watch, type MaybeRefOrGetter } from 'vue'
import type { ACPAgentDef } from '@open-pencil/core/constants'
import { MCP_INSTALL_COMMAND } from '@/app/ai/acp/setup-prompt'
import { readPiAccount } from '@/app/ai/harness/pi-settings'
import { agentDiscovery, type DetectedAgent, type DiscoveryError } from './discovery'
type AgentDiscovery = typeof agentDiscovery
/** What setup knows about one coding agent and the MCP server it needs. */
export interface AgentSetupState {
/** False where agents cannot run, so only manual instructions apply. */
supported: boolean
/** A check has finished, so a later one keeps showing its results. */
checked: boolean
scanning: boolean
detected: DetectedAgent | null
/** OpenPencil's MCP server, through which agents reach the canvas. */
bridge: boolean
/** The installed MCP server does not match this app's version. */
bridgeOutdated: boolean
/** Installs or updates the MCP server by hand. */
bridgeCommand: string
/** npm, which one-click installation runs. */
npm: boolean
installingAgent: boolean
installingBridge: boolean
error: DiscoveryError | null
}
/** What setup knows about Pi: the Harness companion that runs it and Pi's own settings. */
export interface PiSetupState {
supported: boolean
checked: boolean
scanning: boolean
/** The `openpencil-harness` companion that runs Pi. */
companion: boolean
companionOutdated: boolean
companionCommand: string
bridge: boolean
bridgeOutdated: boolean
bridgeCommand: string
npm: boolean
installingCompanion: boolean
installingBridge: boolean
/** The default model set in Pi, used unless OpenPencil names another. */
defaultModel: string | null
error: DiscoveryError | null
}
/** An OpenPencil companion as setup presents it, with the one-click fix that applies. */
export interface CompanionCheck {
/** Installed and matching this app. */
ready: boolean
outdated: boolean
installing: boolean
/** Absent once ready, and without npm, which one-click installation runs. */
action: 'install' | 'update' | null
}
/** Why setup cannot finish on its own, in the order the person should address it. */
export type SetupProblem = 'needs-npm' | 'install-failed' | 'mcp-start-failed' | 'lookup-failed'
function companionCheck(
installed: boolean,
outdated: boolean,
installing: boolean,
npm: boolean
): CompanionCheck {
const ready = installed && !outdated
if (ready || !npm) return { ready, outdated, installing, action: null }
return { ready, outdated, installing, action: outdated ? 'update' : 'install' }
}
function setupProblem(
error: DiscoveryError | null,
needsNpm: boolean,
installErrors: readonly DiscoveryError[]
): SetupProblem | null {
if (error === 'npm' || needsNpm) return 'needs-npm'
if (error !== null && installErrors.includes(error)) return 'install-failed'
if (error === 'canvas-start') return 'mcp-start-failed'
if (error === 'lookup') return 'lookup-failed'
return null
}
/** A coding agent's setup: its CLI and adapter, the MCP server, and what to run by hand. */
export interface AgentSetupView {
bridge: CompanionCheck
/** The adapter can be installed with one click. */
canInstallAdapter: boolean
busy: boolean
problem: SetupProblem | null
/** Commands for where one-click installation cannot help. */
manualAgentCommand: string | null
manualBridgeCommand: string | null
}
export function agentSetupView(agent: ACPAgentDef, setup: AgentSetupState): AgentSetupView {
const bridge = companionCheck(
setup.bridge,
setup.bridgeOutdated,
setup.installingBridge,
setup.npm
)
const status = setup.detected?.status
const needsNpm =
!setup.npm && (status === 'needs-adapter' || (setup.detected !== null && !bridge.ready))
let manualAgentCommand: string | null = null
if (agent.installCommand && status !== 'available') {
const oneClick = setup.supported && setup.npm && setup.error !== 'install'
manualAgentCommand = oneClick && agent.adapterPackage ? null : agent.installCommand
}
let manualBridgeCommand: string | null = null
if (!setup.supported) manualBridgeCommand = MCP_INSTALL_COMMAND
else if (!bridge.ready && (!setup.npm || setup.error === 'canvas-install')) {
manualBridgeCommand = setup.bridgeCommand
}
return {
bridge,
canInstallAdapter: status === 'needs-adapter' && setup.npm,
busy: setup.installingAgent || setup.installingBridge,
problem: setupProblem(setup.error, needsNpm, ['install', 'canvas-install']),
manualAgentCommand,
manualBridgeCommand
}
}
/** Pi's setup: the Harness companion, the MCP server, and what to run by hand. */
export interface PiSetupView {
companion: CompanionCheck
bridge: CompanionCheck
busy: boolean
problem: SetupProblem | null
manualCommands: string[]
}
export function piSetupView(setup: PiSetupState): PiSetupView {
const companion = companionCheck(
setup.companion,
setup.companionOutdated,
setup.installingCompanion,
setup.npm
)
const bridge = companionCheck(
setup.bridge,
setup.bridgeOutdated,
setup.installingBridge,
setup.npm
)
const missing = !companion.ready || !bridge.ready
const manual = !setup.npm || setup.error === 'harness-install' || setup.error === 'canvas-install'
return {
companion,
bridge,
busy: setup.installingCompanion || setup.installingBridge,
problem: setupProblem(setup.error, setup.checked && !setup.npm && missing, [
'harness-install',
'canvas-install'
]),
manualCommands: manual
? [
...(companion.ready ? [] : [setup.companionCommand]),
...(bridge.ready ? [] : [setup.bridgeCommand])
]
: []
}
}
/** Setup state of the coding agents and Pi, for guided setup and model settings. */
export function useAgentSetup(
discovery: AgentDiscovery = agentDiscovery,
readPi: typeof readPiAccount = readPiAccount
) {
const piDefaultModel = ref<string | null>(null)
const readingPi = ref(false)
function agentID(providerID: string) {
return providerID.startsWith('acp:') ? providerID.slice('acp:'.length) : null
}
function agentSetup(providerID: string): AgentSetupState {
const id = agentID(providerID)
return {
supported: discovery.supported,
checked: discovery.checked.value,
scanning: discovery.scanning.value,
detected: discovery.agents.value.find((agent) => agent.definition.id === id) ?? null,
bridge: discovery.canvasBridgeAvailable.value,
bridgeOutdated: discovery.canvasBridgeOutdated.value,
bridgeCommand: discovery.canvasBridgeCommand.value,
npm: discovery.npmAvailable.value,
installingAgent: discovery.installing.value === id,
installingBridge: discovery.installing.value === 'canvas',
error: discovery.error.value
}
}
function piSetup(providerID: string): PiSetupState | undefined {
if (providerID !== 'harness:pi') return undefined
return {
supported: discovery.supported,
checked: discovery.checked.value,
scanning: discovery.scanning.value || readingPi.value,
companion: discovery.harnessAvailable.value,
companionOutdated: discovery.harnessOutdated.value,
companionCommand: discovery.harnessCommand.value,
bridge: discovery.canvasBridgeAvailable.value,
bridgeOutdated: discovery.canvasBridgeOutdated.value,
bridgeCommand: discovery.canvasBridgeCommand.value,
npm: discovery.npmAvailable.value,
installingCompanion: discovery.installing.value === 'harness',
installingBridge: discovery.installing.value === 'canvas',
defaultModel: piDefaultModel.value,
error: discovery.error.value
}
}
function installAgent(providerID: string): Promise<void> {
if (providerID === 'harness:pi') return discovery.setupHarness()
const detected = agentSetup(providerID).detected
return detected ? discovery.install(detected.definition.id) : Promise.resolve()
}
async function refreshAgents(): Promise<void> {
if (!discovery.supported) return
readingPi.value = true
try {
const [, account] = await Promise.all([discovery.refresh(true), readPi().catch(() => null)])
piDefaultModel.value = account?.defaultModel ?? null
} finally {
readingPi.value = false
}
}
return {
agentSetup,
piSetup,
installAgent,
refreshAgents,
setupCanvasBridge: () => discovery.setupCanvasBridge()
}
}
/**
* Pi's setup while `active`, checked each time it becomes active. Undefined outside the desktop
* app, where Pi cannot run and there is nothing to check.
*/
export function usePiSetup(active: MaybeRefOrGetter<boolean>, setup = useAgentSetup()) {
const state = computed(() => {
const pi = toValue(active) ? setup.piSetup('harness:pi') : undefined
return pi?.supported ? pi : undefined
})
watch(
() => toValue(active),
(on) => {
if (on) void setup.refreshAgents()
},
{ immediate: true }
)
return {
state,
refresh: setup.refreshAgents,
installCompanion: () => setup.installAgent('harness:pi'),
installBridge: setup.setupCanvasBridge
}
}

View file

@ -1,119 +0,0 @@
import { ref } from 'vue'
import { agentDiscovery, type DetectedAgent } from '@/app/ai/agents/discovery'
import { readPiAccount } from '@/app/ai/harness/pi-settings'
import type { OnboardingAccess } from './plan'
type AgentDiscovery = typeof agentDiscovery
/** What guided setup knows about one coding agent and the MCP server it needs. */
export interface AgentSetupState {
/** False where agents cannot run, so only manual instructions apply. */
supported: boolean
scanning: boolean
detected: DetectedAgent | null
/** OpenPencil's MCP server, through which agents reach the canvas. */
bridge: boolean
/** The installed MCP server does not match this app's version. */
bridgeOutdated: boolean
/** Installs or updates the MCP server by hand. */
bridgeCommand: string
/** npm, which one-click installation runs. */
npm: boolean
installingAgent: boolean
installingBridge: boolean
error: AgentDiscovery['error']['value']
}
/** What guided setup knows about Pi: the Harness companion that runs it and Pi's own settings. */
export interface PiSetupState {
supported: boolean
scanning: boolean
/** The `openpencil-harness` companion that runs Pi. */
companion: boolean
companionOutdated: boolean
companionCommand: string
bridge: boolean
bridgeOutdated: boolean
bridgeCommand: string
npm: boolean
installingCompanion: boolean
installingBridge: boolean
/** The default model set in Pi, used unless OpenPencil names another. */
defaultModel: string | null
error: AgentDiscovery['error']['value']
}
/** Coding agent setup in guided setup, backed by the desktop agent discovery. */
export function useOnboardingAgents(
discovery: AgentDiscovery = agentDiscovery,
readPi: typeof readPiAccount = readPiAccount
) {
const piDefaultModel = ref<string | null>(null)
const readingPi = ref(false)
function agentID(providerID: OnboardingAccess) {
return providerID.startsWith('acp:') ? providerID.slice('acp:'.length) : null
}
function agentSetup(providerID: OnboardingAccess): AgentSetupState {
const id = agentID(providerID)
return {
supported: discovery.supported,
scanning: discovery.scanning.value,
detected: discovery.agents.value.find((agent) => agent.definition.id === id) ?? null,
bridge: discovery.canvasBridgeAvailable.value,
bridgeOutdated: discovery.canvasBridgeOutdated.value,
bridgeCommand: discovery.canvasBridgeCommand.value,
npm: discovery.npmAvailable.value,
installingAgent: discovery.installing.value === id,
installingBridge: discovery.installing.value === 'canvas',
error: discovery.error.value
}
}
function piSetup(providerID: OnboardingAccess): PiSetupState | undefined {
if (providerID !== 'harness:pi') return undefined
return {
supported: discovery.supported,
scanning: discovery.scanning.value || readingPi.value,
companion: discovery.harnessAvailable.value,
companionOutdated: discovery.harnessOutdated.value,
companionCommand: discovery.harnessCommand.value,
bridge: discovery.canvasBridgeAvailable.value,
bridgeOutdated: discovery.canvasBridgeOutdated.value,
bridgeCommand: discovery.canvasBridgeCommand.value,
npm: discovery.npmAvailable.value,
installingCompanion: discovery.installing.value === 'harness',
installingBridge: discovery.installing.value === 'canvas',
defaultModel: piDefaultModel.value,
error: discovery.error.value
}
}
function installAgent(providerID: OnboardingAccess): Promise<void> {
if (providerID === 'harness:pi') return discovery.setupHarness()
const detected = agentSetup(providerID).detected
return detected ? discovery.install(detected.definition.id) : Promise.resolve()
}
async function refreshAgents(): Promise<void> {
if (!discovery.supported) return
readingPi.value = true
try {
const [, account] = await Promise.all([discovery.refresh(true), readPi().catch(() => null)])
piDefaultModel.value = account?.defaultModel ?? null
} finally {
readingPi.value = false
}
}
return {
agentSetup,
piSetup,
installAgent,
refreshAgents,
setupCanvasBridge: () => discovery.setupCanvasBridge()
}
}

View file

@ -40,6 +40,15 @@ export const ONBOARDING_SERVER_PRESETS = [
{ id: 'lmstudio', name: 'LM Studio', baseURL: 'http://localhost:1234/v1' }
] as const
export type OnboardingServerPreset = (typeof ONBOARDING_SERVER_PRESETS)[number]['id'] | 'custom'
/** The preset whose address the person entered, or `custom` for any other server. */
export function serverPresetFor(baseURL: string): OnboardingServerPreset {
return (
ONBOARDING_SERVER_PRESETS.find((preset) => preset.baseURL === baseURL.trim())?.id ?? 'custom'
)
}
/** Details the person edits while connecting. */
export type OnboardingConnectionPatch = Partial<
Pick<OnboardingConnectionState, 'apiKey' | 'customBaseURL' | 'customModelID'>
@ -147,6 +156,12 @@ export function useOnboardingConnections({ plannedModel }: OnboardingConnections
state.reason = null
}
/** Applies what the person typed; a changed connection needs a new test. */
function updateConnection(providerID: OnboardingAccess, patch: OnboardingConnectionPatch): void {
Object.assign(connection(providerID), patch)
resetTest(providerID)
}
async function testConnection(providerID: OnboardingAccess): Promise<void> {
const request = (testVersions.get(providerID) ?? 0) + 1
testVersions.set(providerID, request)
@ -182,5 +197,14 @@ export function useOnboardingConnections({ plannedModel }: OnboardingConnections
keyStatuses[connectionId] = 'configured'
}
return { connection, hasSavedKey, ready, resetTest, testConnection, clearKeys, markKeySaved }
return {
connection,
hasSavedKey,
ready,
resetTest,
updateConnection,
testConnection,
clearKeys,
markKeySaved
}
}

View file

@ -64,14 +64,6 @@ export interface PlannedModel {
/** A model for a role, the design model (`'design'`), or nothing. */
export type PlannedRole = PlannedModel | 'design' | null
/** Roles in the order setup shows them. */
export const ONBOARDING_ROLES = [
'design',
'vision',
'review',
'fast'
] as const satisfies readonly AIModelRole[]
export interface OnboardingPlan {
/** `null` keeps the design model that is configured now. */
design: PlannedModel | null

View file

@ -1,9 +1,8 @@
import { computed, ref, type ComputedRef } from 'vue'
import type { AIModelRole } from '@/app/ai/models/types'
import { AI_MODEL_ROLES, type AIModelRole } from '@/app/ai/models/types'
import {
ONBOARDING_ROLES,
planConnections,
roleChoiceKey,
roleOptions,
@ -45,7 +44,7 @@ export function useOnboardingRoles(
})
const isRecommended = computed(() =>
ONBOARDING_ROLES.every(
AI_MODEL_ROLES.every(
(role) => roleChoiceKey(plan.value[role]) === roleChoiceKey(recommended.value[role])
)
)

View file

@ -2,6 +2,7 @@ import { computed, reactive, ref, watch } from 'vue'
import { IS_TAURI } from '@open-pencil/core/constants'
import { useAgentSetup } from '@/app/ai/agents/setup'
import { refreshAIProviderStatus } from '@/app/ai/chat/storage'
import {
aiModelSettings,
@ -11,14 +12,15 @@ import {
} from '@/app/ai/models'
import type { SettingsSaveResult } from '@/app/settings/save-result'
import { useOnboardingAgents } from './agents'
import { applyOnboardingPlan } from './apply'
import { existingOnboardingConnection, useOnboardingConnections } from './connections'
import { currentOnboardingModels } from './current'
import {
isOnboardingAccess,
ONBOARDING_GOALS,
planOnboarding,
type OnboardingAccess,
type OnboardingGoal,
type OnboardingAnswers,
type PlannedModel,
uncoveredGoals
@ -78,7 +80,7 @@ export function useAIOnboarding({ agentsAvailable = IS_TAURI }: AIOnboardingOpti
const connections = useOnboardingConnections({ plannedModel })
const { connection, ready, markKeySaved } = connections
const signIn = useOnboardingSignIn(connections)
const agents = useOnboardingAgents()
const agents = useAgentSetup()
// Look for installed agents when they are about to be connected, and again on return.
watch(step, (current) => {
if (current === 'connect') void agents.refreshAgents()
@ -92,6 +94,13 @@ export function useAIOnboarding({ agentsAvailable = IS_TAURI }: AIOnboardingOpti
return true
})
/** Keeps goals in their canonical order however they were picked. */
function setGoal(goal: OnboardingGoal, wanted: boolean): void {
answers.goals = ONBOARDING_GOALS.filter((candidate) =>
candidate === goal ? wanted : answers.goals.includes(candidate)
)
}
function next(): void {
const index = AI_SETUP_STEPS.indexOf(step.value)
if (canContinue.value && index < AI_SETUP_STEPS.length - 1) {
@ -165,6 +174,7 @@ export function useAIOnboarding({ agentsAvailable = IS_TAURI }: AIOnboardingOpti
...connections,
...signIn,
...agents,
setGoal,
next,
back,
apply

View file

@ -0,0 +1,129 @@
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { tv } from 'tailwind-variants'
import { computed } from 'vue'
import type { ACPAgentDef } from '@open-pencil/core/constants'
import { useI18n } from '@open-pencil/vue'
import { codingAgentGuideURL, codingAgentSetupPrompt } from '@/app/ai/acp/setup-prompt'
import { agentSetupView, type AgentSetupState } from '@/app/ai/agents/setup'
import SettingsLink from '@/components/settings/layout/SettingsLink.vue'
import AppButton from '@/components/ui/button/AppButton.vue'
import AppAlert from '@/components/ui/feedback/AppAlert.vue'
import AppCopyField from '@/components/ui/input/AppCopyField.vue'
import { NODE_DOWNLOAD_URL } from '@/constants'
import theme from '@/theme/settings/agents'
import AgentSetupItem from './AgentSetupItem.vue'
import { useSetupProblemMessage } from './problem'
const { agent, setup } = defineProps<{ agent: ACPAgentDef; setup: AgentSetupState }>()
const emit = defineEmits<{ check: []; installAgent: []; installBridge: [] }>()
const { ai, common } = useI18n()
const styles = tv(theme)()
const setupProblemMessage = useSetupProblemMessage()
const { copy, copied } = useClipboard({ copiedDuring: 1500 })
const detected = computed(() => setup.detected)
const prompt = computed(() => codingAgentSetupPrompt(agent.id))
const view = computed(() => agentSetupView(agent, setup))
const agentAction = computed(() => {
if (!view.value.canInstallAdapter) return undefined
return setup.installingAgent
? ai.value.aiSetupAgentInstalling
: ai.value.aiSetupAgentInstallAdapter
})
const bridgeState = computed(() => {
if (setup.bridgeOutdated) return ai.value.aiSetupAgentOutdated
return setup.bridge ? ai.value.aiSetupAgentInstalled : ai.value.aiSetupAgentNotFound
})
const bridgeAction = computed(() => {
const { action, installing } = view.value.bridge
if (!action) return undefined
if (installing) return ai.value.aiSetupAgentInstalling
return action === 'update' ? ai.value.aiSetupAgentUpdateMCP : ai.value.aiSetupAgentInstallMCP
})
const problemMessage = computed(() => setupProblemMessage(view.value.problem))
function agentState(): string {
if (detected.value?.status === 'available') return ai.value.aiSetupAgentInstalled
if (detected.value?.status === 'needs-adapter') return ai.value.aiSetupAgentNeedsAdapter
return ai.value.aiSetupAgentNotFound
}
</script>
<template>
<p :class="styles.help()">{{ ai.aiSetupAgentDescription({ agent: agent.name }) }}</p>
<template v-if="setup.supported">
<p v-if="setup.scanning && !setup.checked" role="status" :class="styles.status()">
<icon-lucide-loader-2 :class="styles.spinner()" aria-hidden="true" />
{{ ai.aiSetupAgentChecking }}
</p>
<ul v-else-if="detected" :class="styles.list()">
<AgentSetupItem
:ready="detected.status === 'available'"
:action="agentAction"
:loading="setup.installingAgent"
:disabled="view.busy"
@action="emit('installAgent')"
>
{{ agent.name }} · {{ agentState() }}
<template v-if="detected.status === 'not-installed' && agent.setupURL" #action>
<SettingsLink :href="agent.setupURL">
{{ ai.aiSetupAgentGetCLI({ agent: agent.name }) }}
</SettingsLink>
</template>
</AgentSetupItem>
<AgentSetupItem
:ready="view.bridge.ready"
:action="bridgeAction"
:loading="setup.installingBridge"
:disabled="view.busy"
@action="emit('installBridge')"
>
{{ ai.aiSetupAgentMCP }} · {{ bridgeState }}
</AgentSetupItem>
</ul>
<AppAlert v-if="problemMessage" tone="warning" :heading="problemMessage">
<template v-if="view.problem === 'needs-npm'" #actions>
<SettingsLink :href="NODE_DOWNLOAD_URL">Node.js</SettingsLink>
</template>
</AppAlert>
</template>
<template v-if="view.manualAgentCommand">
<p :class="styles.help()">{{ ai.aiSetupAgentInstall }}</p>
<AppCopyField
:value="view.manualAgentCommand"
:copy-label="common.copy"
:copied-label="common.copied"
/>
</template>
<template v-if="view.manualBridgeCommand">
<p :class="styles.help()">{{ ai.aiSetupAgentMCPInstall }}</p>
<AppCopyField
:value="view.manualBridgeCommand"
:copy-label="common.copy"
:copied-label="common.copied"
/>
</template>
<p :class="styles.help()">{{ ai.aiSetupAgentPromptHint({ agent: agent.name }) }}</p>
<div :class="styles.actions()">
<AppButton size="xs" variant="outline" @click="copy(prompt)">
<template #leading><icon-lucide-clipboard-copy class="size-3" /></template>
{{ copied ? common.copied : ai.aiSetupAgentCopyPrompt }}
</AppButton>
<AppButton
v-if="setup.supported"
size="xs"
:disabled="view.busy || setup.scanning"
@click="emit('check')"
>
{{ ai.aiSetupAgentCheckAgain }}
</AppButton>
<SettingsLink :href="codingAgentGuideURL(agent.id)">{{ ai.aiSetupAgentGuide }}</SettingsLink>
</div>
</template>

View file

@ -2,7 +2,7 @@
import { tv } from 'tailwind-variants'
import AppButton from '@/components/ui/button/AppButton.vue'
import theme from '@/theme/settings/ai-setup/flow'
import theme from '@/theme/settings/agents'
/** One thing guided setup checks on this computer, with an optional one-click fix. */
const {
@ -23,8 +23,8 @@ const styles = tv(theme)()
</script>
<template>
<li :class="styles.installItem()" :data-status="ready ? 'available' : 'missing'">
<icon-lucide-circle-check v-if="ready" :class="styles.signedInIcon()" aria-hidden="true" />
<li :class="styles.item()" :data-status="ready ? 'available' : 'missing'">
<icon-lucide-circle-check v-if="ready" :class="styles.readyIcon()" aria-hidden="true" />
<icon-lucide-circle-dashed v-else :class="styles.missingIcon()" aria-hidden="true" />
<span class="flex-1"><slot /></span>
<slot name="action">

View file

@ -0,0 +1,108 @@
<script setup lang="ts">
import { tv } from 'tailwind-variants'
import { computed } from 'vue'
import { useI18n } from '@open-pencil/vue'
import { piSetupView, type CompanionCheck, type PiSetupState } from '@/app/ai/agents/setup'
import SettingsLink from '@/components/settings/layout/SettingsLink.vue'
import AppButton from '@/components/ui/button/AppButton.vue'
import AppAlert from '@/components/ui/feedback/AppAlert.vue'
import AppCopyField from '@/components/ui/input/AppCopyField.vue'
import { NODE_DOWNLOAD_URL } from '@/constants'
import theme from '@/theme/settings/agents'
import AgentSetupItem from './AgentSetupItem.vue'
import { useSetupProblemMessage } from './problem'
const { setup } = defineProps<{ setup: PiSetupState }>()
const emit = defineEmits<{ check: []; installCompanion: []; installBridge: [] }>()
const { ai, common } = useI18n()
const styles = tv(theme)()
const setupProblemMessage = useSetupProblemMessage()
const view = computed(() => piSetupView(setup))
const problemMessage = computed(() => setupProblemMessage(view.value.problem))
function state(check: CompanionCheck): string {
if (check.outdated) return ai.value.aiSetupAgentOutdated
return check.ready ? ai.value.aiSetupAgentInstalled : ai.value.aiSetupAgentNotFound
}
/** The button that installs or updates a companion, while one-click installation can help. */
function actionLabel(
check: CompanionCheck,
labels: { install: string; update: string }
): string | undefined {
if (!check.action) return undefined
if (check.installing) return ai.value.aiSetupAgentInstalling
return check.action === 'update' ? labels.update : labels.install
}
</script>
<template>
<p :class="styles.help()">{{ ai.aiSetupPiDescription }}</p>
<p v-if="setup.scanning && !setup.checked" role="status" :class="styles.status()">
<icon-lucide-loader-2 :class="styles.spinner()" aria-hidden="true" />
{{ ai.aiSetupAgentChecking }}
</p>
<ul v-else :class="styles.list()">
<AgentSetupItem
:ready="view.companion.ready"
:action="
actionLabel(view.companion, {
install: ai.aiSetupPiInstallCompanion,
update: ai.aiSetupPiUpdateCompanion
})
"
:loading="view.companion.installing"
:disabled="view.busy"
@action="emit('installCompanion')"
>
{{ ai.aiSetupPiCompanion }} · {{ state(view.companion) }}
</AgentSetupItem>
<AgentSetupItem
:ready="view.bridge.ready"
:action="
actionLabel(view.bridge, {
install: ai.aiSetupAgentInstallMCP,
update: ai.aiSetupAgentUpdateMCP
})
"
:loading="view.bridge.installing"
:disabled="view.busy"
@action="emit('installBridge')"
>
{{ ai.aiSetupAgentMCP }} · {{ state(view.bridge) }}
</AgentSetupItem>
<AgentSetupItem :ready="Boolean(setup.defaultModel)">
{{
setup.defaultModel ? ai.aiSetupPiModel({ model: setup.defaultModel }) : ai.aiSetupPiNoModel
}}
</AgentSetupItem>
</ul>
<AppAlert v-if="problemMessage" tone="warning" :heading="problemMessage">
<template v-if="view.problem === 'needs-npm'" #actions>
<SettingsLink :href="NODE_DOWNLOAD_URL">Node.js</SettingsLink>
</template>
</AppAlert>
<template v-if="view.manualCommands.length">
<p :class="styles.help()">{{ ai.aiSetupAgentInstall }}</p>
<AppCopyField
v-for="command in view.manualCommands"
:key="command"
:value="command"
:copy-label="common.copy"
:copied-label="common.copied"
/>
</template>
<p :class="styles.help()">{{ ai.aiSetupPiSignIn }}</p>
<div :class="styles.actions()">
<AppButton size="xs" :disabled="view.busy || setup.scanning" @click="emit('check')">
{{ ai.aiSetupAgentCheckAgain }}
</AppButton>
</div>
</template>

View file

@ -0,0 +1,15 @@
import { useI18n } from '@open-pencil/vue'
import type { SetupProblem } from '@/app/ai/agents/setup'
/** The warning setup shows for a problem it cannot fix on its own. */
export function useSetupProblemMessage() {
const { ai } = useI18n()
return (problem: SetupProblem | null): string | null => {
if (problem === 'needs-npm') return ai.value.aiSetupAgentNeedsNpm
if (problem === 'install-failed') return ai.value.aiSetupAgentInstallFailed
if (problem === 'mcp-start-failed') return ai.value.aiSetupAgentMCPStartFailed
if (problem === 'lookup-failed') return ai.value.aiSetupAgentLookupFailed
return null
}
}

View file

@ -16,12 +16,10 @@ import SettingsLink from '@/components/settings/layout/SettingsLink.vue'
import ProviderLogo from '@/components/settings/provider/ProviderLogo.vue'
import AppCollapsible from '@/components/ui/collapsible/AppCollapsible.vue'
import AppAlert from '@/components/ui/feedback/AppAlert.vue'
import AppCheckbox from '@/components/ui/toggle/AppCheckbox.vue'
import AppCheckboxCard from '@/components/ui/toggle/AppCheckboxCard.vue'
import { DESKTOP_DOWNLOAD_URL } from '@/constants'
import theme from '@/theme/settings/ai-setup/flow'
import SetupChoice from './SetupChoice.vue'
const { agentsAvailable } = defineProps<{ agentsAvailable: boolean }>()
const access = defineModel<OnboardingAccess[]>({ required: true })
const { ai } = useI18n()
@ -42,18 +40,15 @@ function toggle(providerID: OnboardingAccess, checked: boolean): void {
<h3 :class="styles.groupHeading()">{{ ai.aiSetupAccessAgents }}</h3>
<template v-if="agentsAvailable">
<p :class="styles.help()">{{ ai.aiSetupAccessAgentsDescription }}</p>
<SetupChoice
<AppCheckboxCard
v-for="agent in ONBOARDING_AGENTS"
:key="agent"
:label="modelProviderName(agent)"
:model-value="access.includes(agent)"
@update:model-value="toggle(agent, $event)"
>
<AppCheckbox
:model-value="access.includes(agent)"
:ariaLabel="modelProviderName(agent)"
@update:model-value="toggle(agent, $event)"
/>
<template #icon><ProviderLogo :provider="agent" /></template>
</SetupChoice>
</AppCheckboxCard>
</template>
<AppAlert v-else :heading="ai.aiSetupAccessAgentsDesktop">
<template #actions>
@ -63,47 +58,41 @@ function toggle(providerID: OnboardingAccess, checked: boolean): void {
</section>
<section :class="styles.group()">
<h3 :class="styles.groupHeading()">{{ ai.aiSetupAccessAPI }}</h3>
<SetupChoice
<AppCheckboxCard
v-for="provider in ONBOARDING_API_PROVIDERS"
:key="provider"
:label="modelProviderName(provider)"
:model-value="access.includes(provider)"
@update:model-value="toggle(provider, $event)"
>
<AppCheckbox
:model-value="access.includes(provider)"
:ariaLabel="modelProviderName(provider)"
@update:model-value="toggle(provider, $event)"
/>
<template #icon><ProviderLogo :provider="provider" /></template>
</SetupChoice>
</AppCheckboxCard>
<AppCollapsible
v-model:open="moreOpen"
:label="ai.aiSetupAccessMore"
:ui="{ trigger: styles.moreTrigger() }"
>
<div :class="styles.moreGroup()">
<SetupChoice
<AppCheckboxCard
v-for="provider in ONBOARDING_MORE_API_PROVIDERS"
:key="provider"
:label="modelProviderName(provider)"
:model-value="access.includes(provider)"
@update:model-value="toggle(provider, $event)"
>
<AppCheckbox
:model-value="access.includes(provider)"
:ariaLabel="modelProviderName(provider)"
@update:model-value="toggle(provider, $event)"
/>
<template #icon><ProviderLogo :provider="provider" /></template>
</SetupChoice>
</AppCheckboxCard>
</div>
</AppCollapsible>
</section>
<section :class="styles.group()">
<SetupChoice :label="ai.aiSetupAccessServer" :description="ai.aiSetupAccessServerDescription">
<AppCheckbox
:model-value="access.includes(ONBOARDING_SERVER_PROVIDER)"
:ariaLabel="ai.aiSetupAccessServer"
@update:model-value="toggle(ONBOARDING_SERVER_PROVIDER, $event)"
/>
<AppCheckboxCard
:label="ai.aiSetupAccessServer"
:description="ai.aiSetupAccessServerDescription"
:model-value="access.includes(ONBOARDING_SERVER_PROVIDER)"
@update:model-value="toggle(ONBOARDING_SERVER_PROVIDER, $event)"
>
<template #icon><ProviderLogo :provider="ONBOARDING_SERVER_PROVIDER" /></template>
</SetupChoice>
</AppCheckboxCard>
</section>
</template>

View file

@ -1,163 +0,0 @@
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { tv } from 'tailwind-variants'
import { computed } from 'vue'
import type { ACPAgentDef } from '@open-pencil/core/constants'
import { useI18n } from '@open-pencil/vue'
import {
codingAgentGuideURL,
codingAgentSetupPrompt,
MCP_INSTALL_COMMAND
} from '@/app/ai/acp/setup-prompt'
import type { AgentSetupState } from '@/app/ai/models/settings/onboarding/agents'
import SettingsLink from '@/components/settings/layout/SettingsLink.vue'
import AppButton from '@/components/ui/button/AppButton.vue'
import AppAlert from '@/components/ui/feedback/AppAlert.vue'
import { NODE_DOWNLOAD_URL } from '@/constants'
import theme from '@/theme/settings/ai-setup/flow'
import SetupInstallItem from './SetupInstallItem.vue'
const { agent, setup } = defineProps<{ agent: ACPAgentDef; setup: AgentSetupState }>()
const emit = defineEmits<{ check: []; installAgent: []; installBridge: [] }>()
const { ai, common } = useI18n()
const styles = tv(theme)()
const { copy, copied, text } = useClipboard({ copiedDuring: 1500 })
const detected = computed(() => setup.detected)
const prompt = computed(() => codingAgentSetupPrompt(agent.id))
const busy = computed(() => setup.installingAgent || setup.installingBridge)
const bridgeReady = computed(() => setup.bridge && !setup.bridgeOutdated)
/** Manual commands are for where one-click installation cannot help. */
const manualAgentCommand = computed(() => {
if (!agent.installCommand || detected.value?.status === 'available') return null
if (!setup.supported || !setup.npm || setup.error === 'install') {
return agent.installCommand
}
return agent.adapterPackage ? null : agent.installCommand
})
const manualBridgeCommand = computed(() => {
if (!setup.supported) return MCP_INSTALL_COMMAND
return !bridgeReady.value && (!setup.npm || setup.error === 'canvas-install')
? setup.bridgeCommand
: null
})
const needsNpm = computed(
() =>
!setup.npm &&
(detected.value?.status === 'needs-adapter' || (setup.detected !== null && !bridgeReady.value))
)
const agentAction = computed(() => {
if (detected.value?.status !== 'needs-adapter' || !setup.npm) return undefined
return setup.installingAgent
? ai.value.aiSetupAgentInstalling
: ai.value.aiSetupAgentInstallAdapter
})
const bridgeState = computed(() => {
if (setup.bridgeOutdated) return ai.value.aiSetupAgentOutdated
return setup.bridge ? ai.value.aiSetupAgentInstalled : ai.value.aiSetupAgentNotFound
})
const bridgeAction = computed(() => {
if (bridgeReady.value || !setup.npm) return undefined
if (setup.installingBridge) return ai.value.aiSetupAgentInstalling
return setup.bridgeOutdated ? ai.value.aiSetupAgentUpdateMCP : ai.value.aiSetupAgentInstallMCP
})
const errorMessage = computed(() => {
if (setup.error === 'npm' || needsNpm.value) return ai.value.aiSetupAgentNeedsNpm
if (setup.error === 'install' || setup.error === 'canvas-install') {
return ai.value.aiSetupAgentInstallFailed
}
if (setup.error === 'canvas-start') return ai.value.aiSetupAgentMCPStartFailed
if (setup.error === 'lookup') return ai.value.aiSetupAgentLookupFailed
return null
})
function agentState(): string {
if (detected.value?.status === 'available') return ai.value.aiSetupAgentInstalled
if (detected.value?.status === 'needs-adapter') return ai.value.aiSetupAgentNeedsAdapter
return ai.value.aiSetupAgentNotFound
}
function copiedLabel(value: string, label: string): string {
return copied.value && text.value === value ? common.value.copied : label
}
</script>
<template>
<p :class="styles.help()">{{ ai.aiSetupAgentDescription({ agent: agent.name }) }}</p>
<template v-if="setup.supported">
<p v-if="setup.scanning && !detected" role="status" :class="styles.signInStatus()">
<icon-lucide-loader-2 :class="styles.spinner()" aria-hidden="true" />
{{ ai.aiSetupAgentChecking }}
</p>
<ul v-else-if="detected" :class="styles.installList()">
<SetupInstallItem
:ready="detected.status === 'available'"
:action="agentAction"
:loading="setup.installingAgent"
:disabled="busy"
@action="emit('installAgent')"
>
{{ agent.name }} · {{ agentState() }}
<template v-if="detected.status === 'not-installed' && agent.setupURL" #action>
<SettingsLink :href="agent.setupURL">
{{ ai.aiSetupAgentGetCLI({ agent: agent.name }) }}
</SettingsLink>
</template>
</SetupInstallItem>
<SetupInstallItem
:ready="bridgeReady"
:action="bridgeAction"
:loading="setup.installingBridge"
:disabled="busy"
@action="emit('installBridge')"
>
{{ ai.aiSetupAgentMCP }} · {{ bridgeState }}
</SetupInstallItem>
</ul>
<AppAlert v-if="errorMessage" tone="warning" :heading="errorMessage">
<template v-if="setup.error === 'npm' || needsNpm" #actions>
<SettingsLink :href="NODE_DOWNLOAD_URL">Node.js</SettingsLink>
</template>
</AppAlert>
</template>
<template v-if="manualAgentCommand">
<p :class="styles.help()">{{ ai.aiSetupAgentInstall }}</p>
<div :class="styles.command()">
<code>{{ manualAgentCommand }}</code>
<AppButton size="xs" @click="copy(manualAgentCommand)">
{{ copiedLabel(manualAgentCommand, common.copy) }}
</AppButton>
</div>
</template>
<template v-if="manualBridgeCommand">
<p :class="styles.help()">{{ ai.aiSetupAgentMCPInstall }}</p>
<div :class="styles.command()">
<code>{{ manualBridgeCommand }}</code>
<AppButton size="xs" @click="copy(manualBridgeCommand)">
{{ copiedLabel(manualBridgeCommand, common.copy) }}
</AppButton>
</div>
</template>
<p :class="styles.help()">{{ ai.aiSetupAgentPromptHint({ agent: agent.name }) }}</p>
<div :class="styles.signInActions()">
<AppButton size="xs" variant="outline" @click="copy(prompt)">
<template #leading><icon-lucide-clipboard-copy class="size-3" /></template>
{{ copiedLabel(prompt, ai.aiSetupAgentCopyPrompt) }}
</AppButton>
<AppButton
v-if="setup.supported"
size="xs"
:disabled="busy || setup.scanning"
@click="emit('check')"
>
{{ ai.aiSetupAgentCheckAgain }}
</AppButton>
<SettingsLink :href="codingAgentGuideURL(agent.id)">{{ ai.aiSetupAgentGuide }}</SettingsLink>
</div>
</template>

View file

@ -4,7 +4,7 @@ import { expect, within } from 'storybook/test'
import { ACP_AGENTS, type ACPAgentID } from '@open-pencil/core/constants'
import type { DetectedAgent } from '@/app/ai/agents/discovery'
import type { AgentSetupState, PiSetupState } from '@/app/ai/models/settings/onboarding/agents'
import type { AgentSetupState, PiSetupState } from '@/app/ai/agents/setup'
import AISetupConnection from './AISetupConnection.vue'
@ -32,6 +32,7 @@ function detected(id: ACPAgentID, status: DetectedAgent['status']): DetectedAgen
function agentSetup(overrides: Partial<AgentSetupState>): AgentSetupState {
return {
supported: true,
checked: true,
scanning: false,
detected: null,
bridge: false,
@ -48,6 +49,7 @@ function agentSetup(overrides: Partial<AgentSetupState>): AgentSetupState {
function piSetup(overrides: Partial<PiSetupState>): PiSetupState {
return {
supported: true,
checked: true,
scanning: false,
companion: false,
companionOutdated: false,
@ -137,7 +139,11 @@ export const CodingAgentInBrowser: Story = {
}
}
export const CodingAgentChecking: Story = {
args: { providerID: 'acp:codex', recommended: false, agentSetup: agentSetup({ scanning: true }) }
args: {
providerID: 'acp:codex',
recommended: false,
agentSetup: agentSetup({ scanning: true, checked: false })
}
}
export const CodingAgentInstalled: Story = {
args: {

View file

@ -5,10 +5,10 @@ import { computed } from 'vue'
import { ACP_AGENTS, AI_PROVIDERS } from '@open-pencil/core/constants'
import { useI18n } from '@open-pencil/vue'
import type { AgentSetupState, PiSetupState } from '@/app/ai/agents/setup'
import { modelProviderName } from '@/app/ai/models/provider-name'
import type { AgentSetupState, PiSetupState } from '@/app/ai/models/settings/onboarding/agents'
import {
ONBOARDING_SERVER_PRESETS,
serverPresetFor,
type OnboardingConnectionPatch,
type OnboardingConnectionState
} from '@/app/ai/models/settings/onboarding/connections'
@ -18,20 +18,15 @@ import {
type OnboardingAccess
} from '@/app/ai/models/settings/onboarding/plan'
import type { OnboardingSignInStatus } from '@/app/ai/models/settings/onboarding/sign-in'
import ProviderConnectionTestButton from '@/components/chat/ProviderConnectionTestButton.vue'
import AgentSetup from '@/components/settings/agents/AgentSetup.vue'
import PiSetup from '@/components/settings/agents/PiSetup.vue'
import ProviderConnectionTestButton from '@/components/settings/provider/ProviderConnectionTestButton.vue'
import ProviderLogo from '@/components/settings/provider/ProviderLogo.vue'
import ProviderSettingsField from '@/components/settings/provider/ProviderSettingsField.vue'
import ProviderSettingsInput from '@/components/settings/provider/ProviderSettingsInput.vue'
import ProviderSettingsKeyField from '@/components/settings/provider/ProviderSettingsKeyField.vue'
import AppButton from '@/components/ui/button/AppButton.vue'
import AppAlert from '@/components/ui/feedback/AppAlert.vue'
import SegmentedControl from '@/components/ui/select/SegmentedControl.vue'
import AppCheckbox from '@/components/ui/toggle/AppCheckbox.vue'
import theme from '@/theme/settings/ai-setup/flow'
import AISetupAgent from './AISetupAgent.vue'
import AISetupPi from './AISetupPi.vue'
import SetupChoice from './SetupChoice.vue'
import AISetupServer from './AISetupServer.vue'
import AISetupSignIn from './AISetupSignIn.vue'
const {
providerID,
@ -72,7 +67,7 @@ const emit = defineEmits<{
installAgent: []
installBridge: []
}>()
const { ai, common, credentials } = useI18n()
const { ai, credentials } = useI18n()
const styles = tv(theme)()
const name = computed(() =>
@ -88,27 +83,7 @@ const agent = computed(() =>
const provider = computed(() => AI_PROVIDERS.find((candidate) => candidate.id === providerID))
const server = computed(() => providerID === ONBOARDING_SERVER_PROVIDER)
const supportsSignIn = computed(() => providerID === 'openrouter')
const signInFailure = computed(() => {
if (signInStatus === 'blocked') return ai.value.aiSetupOpenRouterBlocked
if (signInStatus === 'cancelled') return ai.value.aiSetupOpenRouterCancelled
if (signInStatus === 'expired') return ai.value.aiSetupOpenRouterExpired
if (signInStatus === 'failed') return ai.value.aiSetupOpenRouterFailed
return null
})
const serverPreset = computed(
() =>
ONBOARDING_SERVER_PRESETS.find((preset) => preset.baseURL === state.customBaseURL.trim())?.id ??
'custom'
)
const serverPresetOptions = computed(() => [
...ONBOARDING_SERVER_PRESETS.map((preset) => ({ value: preset.id, label: preset.name })),
{ value: 'custom', label: ai.value.aiSetupServerOther }
])
function choosePreset(id: string): void {
const preset = ONBOARDING_SERVER_PRESETS.find((candidate) => candidate.id === id)
emit('update', { customBaseURL: preset?.baseURL ?? '' })
}
const serverPreset = computed(() => serverPresetFor(state.customBaseURL))
const keyHint = computed(() => {
if (hasSavedKey) return ai.value.aiSetupSavedKeyHint
@ -123,7 +98,7 @@ const keyHint = computed(() => {
{{ name }}
</h3>
<AISetupAgent
<AgentSetup
v-if="agent && agentSetup"
:agent="agent"
:setup="agentSetup"
@ -131,7 +106,7 @@ const keyHint = computed(() => {
@install-agent="emit('installAgent')"
@install-bridge="emit('installBridge')"
/>
<AISetupPi
<PiSetup
v-else-if="piSetup"
:setup="piSetup"
@check="emit('checkAgent')"
@ -141,100 +116,23 @@ const keyHint = computed(() => {
<template v-else>
<p v-if="recommended" :class="styles.help()">{{ ai.aiSetupMeteredNote }}</p>
<div v-if="state.account" :class="styles.signIn()" data-slot="signed-in">
<p role="status" :class="styles.signInStatus()">
<icon-lucide-circle-check :class="styles.signedInIcon()" aria-hidden="true" />
<span>
{{ ai.aiSetupOpenRouterSignedInTitle }}
<span v-if="state.account.label" :class="styles.signInDetail()">
{{ ai.aiSetupOpenRouterKeyLabel({ label: state.account.label }) }}
</span>
</span>
</p>
<AppButton size="xs" @click="emit('signOut')">{{ ai.aiSetupOpenRouterChange }}</AppButton>
</div>
<AppAlert
v-if="state.account?.freeTier"
tone="warning"
:heading="ai.aiSetupOpenRouterNoCredits"
<AISetupSignIn
v-if="supportsSignIn"
:account="state.account"
:status="signInStatus"
:disabled="disabled"
@sign-in="emit('signIn')"
@reopen="emit('reopenSignIn')"
@cancel="emit('cancelSignIn')"
@sign-out="emit('signOut')"
/>
<AISetupServer
v-if="server"
:state="state"
:vision="serverVision"
@update="emit('update', $event)"
@vision="emit('serverVision', $event)"
/>
<template v-if="supportsSignIn && !state.account">
<div
v-if="signInStatus === 'waiting' || signInStatus === 'verifying'"
:class="styles.signIn()"
>
<p role="status" :class="styles.signInStatus()">
<icon-lucide-loader-2 :class="styles.spinner()" aria-hidden="true" />
{{
signInStatus === 'waiting'
? ai.aiSetupOpenRouterWaiting
: ai.aiSetupOpenRouterVerifying
}}
</p>
<div :class="styles.signInActions()">
<AppButton
v-if="signInStatus === 'waiting'"
size="xs"
variant="outline"
@click="emit('reopenSignIn')"
>
{{ ai.aiSetupOpenRouterReopen }}
</AppButton>
<AppButton size="xs" @click="emit('cancelSignIn')">{{ common.cancel }}</AppButton>
</div>
</div>
<AppButton
v-else
class="self-start"
color="primary"
variant="solid"
:disabled="disabled"
@click="emit('signIn')"
>
{{ ai.aiSetupOpenRouterSignIn }}
</AppButton>
<AppAlert v-if="signInFailure" tone="warning" :heading="signInFailure" />
<p :class="styles.groupHeading()">{{ ai.aiSetupOpenRouterOrKey }}</p>
</template>
<template v-if="server">
<SegmentedControl
:model-value="serverPreset"
:options="serverPresetOptions"
:label="ai.aiSetupAccessServer"
@update:model-value="choosePreset"
>
<template #option="{ option }">
<span class="flex items-center gap-1.5">
<ProviderLogo :provider="option.value === 'custom' ? providerID : option.value" />
{{ option.label }}
</span>
</template>
</SegmentedControl>
<ProviderSettingsField v-slot="{ control }" :label="ai.baseURL">
<ProviderSettingsInput
v-bind="control"
:model-value="state.customBaseURL"
:aria-label="ai.baseURL"
:placeholder="ai.baseURLPlaceholder"
@update:model-value="emit('update', { customBaseURL: String($event) })"
/>
</ProviderSettingsField>
<ProviderSettingsField v-slot="{ control }" :label="ai.modelID">
<ProviderSettingsInput
v-bind="control"
:model-value="state.customModelID"
:aria-label="ai.modelID"
@update:model-value="emit('update', { customModelID: String($event) })"
/>
</ProviderSettingsField>
<SetupChoice :label="ai.aiSetupServerVision">
<AppCheckbox
:model-value="serverVision"
:ariaLabel="ai.aiSetupServerVision"
@update:model-value="emit('serverVision', $event)"
/>
</SetupChoice>
</template>
<ProviderSettingsKeyField
v-if="!state.account"
:model-value="state.apiKey"

View file

@ -5,26 +5,20 @@ import { computed, ref } from 'vue'
import { IS_TAURI } from '@open-pencil/core/constants'
import { useI18n } from '@open-pencil/vue'
import type { OnboardingConnectionPatch } from '@/app/ai/models/settings/onboarding/connections'
import type { AISetupEntry } from '@/app/ai/models/settings/onboarding/dialog'
import {
ONBOARDING_GOALS,
type OnboardingAccess,
type OnboardingGoal
} from '@/app/ai/models/settings/onboarding/plan'
import type { OnboardingAccess } from '@/app/ai/models/settings/onboarding/plan'
import { AI_SETUP_STEPS, useAIOnboarding } from '@/app/ai/models/settings/onboarding/use'
import SettingsSaveFeedback from '@/components/settings/layout/SettingsSaveFeedback.vue'
import AppButton from '@/components/ui/button/AppButton.vue'
import { AppDialogBody, AppDialogFooter, AppDialogHeader } from '@/components/ui/dialog'
import AppAlert from '@/components/ui/feedback/AppAlert.vue'
import AppCheckbox from '@/components/ui/toggle/AppCheckbox.vue'
import AppCheckboxCard from '@/components/ui/toggle/AppCheckboxCard.vue'
import theme from '@/theme/settings/ai-setup/flow'
import AISetupAccess from './AISetupAccess.vue'
import AISetupConnection from './AISetupConnection.vue'
import AISetupReview from './AISetupReview.vue'
import AISetupRoles from './AISetupRoles.vue'
import SetupChoice from './SetupChoice.vue'
const { entry, agentsAvailable = IS_TAURI } = defineProps<{
entry: AISetupEntry
@ -77,17 +71,6 @@ const header = computed(() => {
}
})
function toggleGoal(goal: OnboardingGoal, checked: boolean): void {
answers.goals = ONBOARDING_GOALS.filter((candidate) =>
candidate === goal ? checked : answers.goals.includes(candidate)
)
}
function updateConnection(providerID: OnboardingAccess, patch: OnboardingConnectionPatch): void {
Object.assign(onboarding.connection(providerID), patch)
onboarding.resetTest(providerID)
}
/** Runs in the click handler, so the browser allows the sign-in popup. */
function signIn(providerID: OnboardingAccess): void {
onboarding.signIn(providerID, { keyLabel: 'OpenPencil' })
@ -129,18 +112,14 @@ async function finish(): Promise<void> {
</template>
<template v-else-if="step === 'goals'">
<SetupChoice
<AppCheckboxCard
v-for="goal in goals"
:key="goal.id"
:label="goal.label"
:description="goal.description"
>
<AppCheckbox
:model-value="answers.goals.includes(goal.id)"
:ariaLabel="goal.label"
@update:model-value="toggleGoal(goal.id, $event)"
/>
</SetupChoice>
:model-value="answers.goals.includes(goal.id)"
@update:model-value="onboarding.setGoal(goal.id, $event)"
/>
</template>
<template v-else-if="step === 'access'">
@ -190,7 +169,7 @@ async function finish(): Promise<void> {
:pi-setup="onboarding.piSetup(providerID)"
:recommended="!answers.access.includes(providerID)"
:disabled="busy"
@update="updateConnection(providerID, $event)"
@update="onboarding.updateConnection(providerID, $event)"
@test="onboarding.testConnection(providerID)"
@sign-in="signIn(providerID)"
@reopen-sign-in="onboarding.reopenSignIn(providerID)"

View file

@ -1,142 +0,0 @@
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { tv } from 'tailwind-variants'
import { computed, ref, watch } from 'vue'
import { useI18n } from '@open-pencil/vue'
import type { PiSetupState } from '@/app/ai/models/settings/onboarding/agents'
import SettingsLink from '@/components/settings/layout/SettingsLink.vue'
import AppButton from '@/components/ui/button/AppButton.vue'
import AppAlert from '@/components/ui/feedback/AppAlert.vue'
import { NODE_DOWNLOAD_URL } from '@/constants'
import theme from '@/theme/settings/ai-setup/flow'
import SetupInstallItem from './SetupInstallItem.vue'
const { setup } = defineProps<{ setup: PiSetupState }>()
const emit = defineEmits<{ check: []; installCompanion: []; installBridge: [] }>()
const { ai, common } = useI18n()
const styles = tv(theme)()
const { copy, copied, text } = useClipboard({ copiedDuring: 1500 })
/** Keeps showing results during a later check instead of flashing back to a spinner. */
const checked = ref(false)
watch(
() => setup.scanning,
(scanning) => {
if (!scanning) checked.value = true
},
{ immediate: true }
)
const busy = computed(() => setup.installingCompanion || setup.installingBridge)
const companionReady = computed(() => setup.companion && !setup.companionOutdated)
const bridgeReady = computed(() => setup.bridge && !setup.bridgeOutdated)
const needsNpm = computed(
() => checked.value && !setup.npm && (!companionReady.value || !bridgeReady.value)
)
/** Manual commands are for where one-click installation cannot help. */
const manualCommands = computed(() => {
const manual = !setup.npm || setup.error === 'harness-install' || setup.error === 'canvas-install'
if (!manual) return []
return [
...(companionReady.value ? [] : [setup.companionCommand]),
...(bridgeReady.value ? [] : [setup.bridgeCommand])
]
})
const errorMessage = computed(() => {
if (setup.error === 'npm' || needsNpm.value) return ai.value.aiSetupAgentNeedsNpm
if (setup.error === 'harness-install' || setup.error === 'canvas-install') {
return ai.value.aiSetupAgentInstallFailed
}
if (setup.error === 'canvas-start') return ai.value.aiSetupAgentMCPStartFailed
if (setup.error === 'lookup') return ai.value.aiSetupAgentLookupFailed
return null
})
function state(installed: boolean, outdated: boolean): string {
if (outdated) return ai.value.aiSetupAgentOutdated
return installed ? ai.value.aiSetupAgentInstalled : ai.value.aiSetupAgentNotFound
}
/** The button that installs or updates a companion, while one-click installation can help. */
function actionLabel(
ready: boolean,
installing: boolean,
outdated: boolean,
labels: { install: string; update: string }
): string | undefined {
if (ready || !setup.npm) return undefined
if (installing) return ai.value.aiSetupAgentInstalling
return outdated ? labels.update : labels.install
}
function copiedLabel(value: string): string {
return copied.value && text.value === value ? common.value.copied : common.value.copy
}
</script>
<template>
<p :class="styles.help()">{{ ai.aiSetupPiDescription }}</p>
<p v-if="setup.scanning && !checked" role="status" :class="styles.signInStatus()">
<icon-lucide-loader-2 :class="styles.spinner()" aria-hidden="true" />
{{ ai.aiSetupAgentChecking }}
</p>
<ul v-else :class="styles.installList()">
<SetupInstallItem
:ready="companionReady"
:action="
actionLabel(companionReady, setup.installingCompanion, setup.companionOutdated, {
install: ai.aiSetupPiInstallCompanion,
update: ai.aiSetupPiUpdateCompanion
})
"
:loading="setup.installingCompanion"
:disabled="busy"
@action="emit('installCompanion')"
>
{{ ai.aiSetupPiCompanion }} · {{ state(setup.companion, setup.companionOutdated) }}
</SetupInstallItem>
<SetupInstallItem
:ready="bridgeReady"
:action="
actionLabel(bridgeReady, setup.installingBridge, setup.bridgeOutdated, {
install: ai.aiSetupAgentInstallMCP,
update: ai.aiSetupAgentUpdateMCP
})
"
:loading="setup.installingBridge"
:disabled="busy"
@action="emit('installBridge')"
>
{{ ai.aiSetupAgentMCP }} · {{ state(setup.bridge, setup.bridgeOutdated) }}
</SetupInstallItem>
<SetupInstallItem :ready="Boolean(setup.defaultModel)">
{{
setup.defaultModel ? ai.aiSetupPiModel({ model: setup.defaultModel }) : ai.aiSetupPiNoModel
}}
</SetupInstallItem>
</ul>
<AppAlert v-if="errorMessage" tone="warning" :heading="errorMessage">
<template v-if="setup.error === 'npm' || needsNpm" #actions>
<SettingsLink :href="NODE_DOWNLOAD_URL">Node.js</SettingsLink>
</template>
</AppAlert>
<template v-if="manualCommands.length">
<p :class="styles.help()">{{ ai.aiSetupAgentInstall }}</p>
<div v-for="command in manualCommands" :key="command" :class="styles.command()">
<code>{{ command }}</code>
<AppButton size="xs" @click="copy(command)">{{ copiedLabel(command) }}</AppButton>
</div>
</template>
<p :class="styles.help()">{{ ai.aiSetupPiSignIn }}</p>
<div :class="styles.signInActions()">
<AppButton size="xs" :disabled="busy || setup.scanning" @click="emit('check')">
{{ ai.aiSetupAgentCheckAgain }}
</AppButton>
</div>
</template>

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { ONBOARDING_ROLES, type OnboardingPlan } from '@/app/ai/models/settings/onboarding/plan'
import type { OnboardingPlan } from '@/app/ai/models/settings/onboarding/plan'
import { AI_MODEL_ROLES } from '@/app/ai/models/types'
import SettingsGroup from '@/components/settings/layout/SettingsGroup.vue'
import SettingsRow from '@/components/settings/layout/SettingsRow.vue'
@ -12,7 +13,7 @@ const { choiceLabel, roleLabel } = useRoleLabels()
<template>
<SettingsGroup>
<SettingsRow
v-for="role in ONBOARDING_ROLES"
v-for="role in AI_MODEL_ROLES"
:key="role"
:label="roleLabel(role).label"
:description="choiceLabel(plan[role])"

View file

@ -2,12 +2,11 @@
import { useI18n } from '@open-pencil/vue'
import {
ONBOARDING_ROLES,
roleChoiceKey,
type OnboardingPlan,
type PlannedRole
} from '@/app/ai/models/settings/onboarding/plan'
import type { AIModelRole } from '@/app/ai/models/types'
import { AI_MODEL_ROLES, type AIModelRole } from '@/app/ai/models/types'
import SettingsGroup from '@/components/settings/layout/SettingsGroup.vue'
import SettingsRow from '@/components/settings/layout/SettingsRow.vue'
import AppButton from '@/components/ui/button/AppButton.vue'
@ -35,7 +34,7 @@ function selectOptions(role: AIModelRole) {
<template>
<SettingsGroup>
<SettingsRow
v-for="role in ONBOARDING_ROLES"
v-for="role in AI_MODEL_ROLES"
:key="role"
class="max-sm:flex-col max-sm:items-stretch"
:label="roleLabel(role).label"

View file

@ -0,0 +1,80 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '@open-pencil/vue'
import {
ONBOARDING_SERVER_PRESETS,
serverPresetFor,
type OnboardingConnectionPatch,
type OnboardingConnectionState
} from '@/app/ai/models/settings/onboarding/connections'
import { ONBOARDING_SERVER_PROVIDER } from '@/app/ai/models/settings/onboarding/plan'
import ProviderLogo from '@/components/settings/provider/ProviderLogo.vue'
import ProviderSettingsField from '@/components/settings/provider/ProviderSettingsField.vue'
import ProviderSettingsInput from '@/components/settings/provider/ProviderSettingsInput.vue'
import SegmentedControl from '@/components/ui/select/SegmentedControl.vue'
import AppCheckboxCard from '@/components/ui/toggle/AppCheckboxCard.vue'
/** A local or company server: a preset or any address, its model, and whether it reads images. */
const { state, vision = false } = defineProps<{
state: Pick<OnboardingConnectionState, 'customBaseURL' | 'customModelID'>
vision?: boolean
}>()
const emit = defineEmits<{ update: [patch: OnboardingConnectionPatch]; vision: [value: boolean] }>()
const { ai } = useI18n()
const preset = computed(() => serverPresetFor(state.customBaseURL))
const presetOptions = computed(() => [
...ONBOARDING_SERVER_PRESETS.map((candidate) => ({
value: candidate.id,
label: candidate.name
})),
{ value: 'custom', label: ai.value.aiSetupServerOther }
])
function choosePreset(id: string): void {
const chosen = ONBOARDING_SERVER_PRESETS.find((candidate) => candidate.id === id)
emit('update', { customBaseURL: chosen?.baseURL ?? '' })
}
</script>
<template>
<SegmentedControl
:model-value="preset"
:options="presetOptions"
:label="ai.aiSetupAccessServer"
@update:model-value="choosePreset"
>
<template #option="{ option }">
<span class="flex items-center gap-1.5">
<ProviderLogo
:provider="option.value === 'custom' ? ONBOARDING_SERVER_PROVIDER : option.value"
/>
{{ option.label }}
</span>
</template>
</SegmentedControl>
<ProviderSettingsField v-slot="{ control }" :label="ai.baseURL">
<ProviderSettingsInput
v-bind="control"
:model-value="state.customBaseURL"
:aria-label="ai.baseURL"
:placeholder="ai.baseURLPlaceholder"
@update:model-value="emit('update', { customBaseURL: String($event) })"
/>
</ProviderSettingsField>
<ProviderSettingsField v-slot="{ control }" :label="ai.modelID">
<ProviderSettingsInput
v-bind="control"
:model-value="state.customModelID"
:aria-label="ai.modelID"
@update:model-value="emit('update', { customModelID: String($event) })"
/>
</ProviderSettingsField>
<AppCheckboxCard
:label="ai.aiSetupServerVision"
:model-value="vision"
@update:model-value="emit('vision', $event)"
/>
</template>

View file

@ -0,0 +1,78 @@
<script setup lang="ts">
import { tv } from 'tailwind-variants'
import { computed } from 'vue'
import { useI18n } from '@open-pencil/vue'
import type { OnboardingConnectionState } from '@/app/ai/models/settings/onboarding/connections'
import type { OnboardingSignInStatus } from '@/app/ai/models/settings/onboarding/sign-in'
import AppButton from '@/components/ui/button/AppButton.vue'
import AppAlert from '@/components/ui/feedback/AppAlert.vue'
import theme from '@/theme/settings/ai-setup/flow'
/** Signing in to OpenRouter instead of pasting a key, and the account it signed in to. */
const {
account,
status = 'idle',
disabled = false
} = defineProps<{
account: OnboardingConnectionState['account']
status?: OnboardingSignInStatus
disabled?: boolean
}>()
const emit = defineEmits<{ signIn: []; reopen: []; cancel: []; signOut: [] }>()
const { ai, common } = useI18n()
const styles = tv(theme)()
const failure = computed(() => {
if (status === 'blocked') return ai.value.aiSetupOpenRouterBlocked
if (status === 'cancelled') return ai.value.aiSetupOpenRouterCancelled
if (status === 'expired') return ai.value.aiSetupOpenRouterExpired
if (status === 'failed') return ai.value.aiSetupOpenRouterFailed
return null
})
</script>
<template>
<template v-if="account">
<div :class="styles.signIn()" data-slot="signed-in">
<p role="status" :class="styles.signInStatus()">
<icon-lucide-circle-check :class="styles.signedInIcon()" aria-hidden="true" />
<span>
{{ ai.aiSetupOpenRouterSignedInTitle }}
<span v-if="account.label" :class="styles.signInDetail()">
{{ ai.aiSetupOpenRouterKeyLabel({ label: account.label }) }}
</span>
</span>
</p>
<AppButton size="xs" @click="emit('signOut')">{{ ai.aiSetupOpenRouterChange }}</AppButton>
</div>
<AppAlert v-if="account.freeTier" tone="warning" :heading="ai.aiSetupOpenRouterNoCredits" />
</template>
<template v-else>
<div v-if="status === 'waiting' || status === 'verifying'" :class="styles.signIn()">
<p role="status" :class="styles.signInStatus()">
<icon-lucide-loader-2 :class="styles.spinner()" aria-hidden="true" />
{{ status === 'waiting' ? ai.aiSetupOpenRouterWaiting : ai.aiSetupOpenRouterVerifying }}
</p>
<div :class="styles.signInActions()">
<AppButton v-if="status === 'waiting'" size="xs" variant="outline" @click="emit('reopen')">
{{ ai.aiSetupOpenRouterReopen }}
</AppButton>
<AppButton size="xs" @click="emit('cancel')">{{ common.cancel }}</AppButton>
</div>
</div>
<AppButton
v-else
class="self-start"
color="primary"
variant="solid"
:disabled="disabled"
@click="emit('signIn')"
>
{{ ai.aiSetupOpenRouterSignIn }}
</AppButton>
<AppAlert v-if="failure" tone="warning" :heading="failure" />
<p :class="styles.groupHeading()">{{ ai.aiSetupOpenRouterOrKey }}</p>
</template>
</template>

View file

@ -1,19 +0,0 @@
<script setup lang="ts">
import { tv } from 'tailwind-variants'
import theme from '@/theme/settings/ai-setup/flow'
const { label, description } = defineProps<{ label: string; description?: string }>()
const styles = tv(theme)()
</script>
<template>
<label :class="styles.choice()">
<slot />
<slot name="icon" />
<span
><span class="font-medium">{{ label }}</span
><span v-if="description" class="mt-1 block text-muted">{{ description }}</span></span
>
</label>
</template>

View file

@ -2,11 +2,12 @@ import { useI18n } from '@open-pencil/vue'
import { modelProviderName } from '@/app/ai/models/provider-name'
import { isOnboardingAgent, type PlannedRole } from '@/app/ai/models/settings/onboarding/plan'
import type { AIModelRole } from '@/app/ai/models/types'
import { useModelRoleLabels } from '@/components/settings/models/role-labels'
/** Labels for the roles and the choices guided setup offers for them. */
export function useRoleLabels() {
const { ai } = useI18n()
const roleLabel = useModelRoleLabels()
function choiceLabel(choice: PlannedRole): string {
if (choice === 'design') return ai.value.modelRoleUseDesign
@ -16,16 +17,5 @@ export function useRoleLabels() {
return choice.modelID && choice.name !== provider ? `${choice.name} · ${provider}` : provider
}
function roleLabel(role: AIModelRole): { label: string; description: string } {
const roles = {
design: [ai.value.modelRoleDesign, ai.value.modelRoleDesignDescription],
vision: [ai.value.modelRoleVision, ai.value.modelRoleVisionDescription],
review: [ai.value.modelRoleReview, ai.value.modelRoleReviewDescription],
fast: [ai.value.modelRoleFast, ai.value.modelRoleFastDescription]
} as const
const [label, description] = roles[role]
return { label, description }
}
return { choiceLabel, roleLabel }
}

View file

@ -1,5 +1,4 @@
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { computed } from 'vue'
import { useAutomationMessages, useCommonMessages, useSettingsMessages } from '@open-pencil/vue'
@ -13,6 +12,7 @@ import SettingsGroup from '@/components/settings/layout/SettingsGroup.vue'
import SettingsRow from '@/components/settings/layout/SettingsRow.vue'
import SettingsSection from '@/components/settings/layout/SettingsSection.vue'
import AppButton from '@/components/ui/button/AppButton.vue'
import AppCopyField from '@/components/ui/input/AppCopyField.vue'
import AppSwitch from '@/components/ui/toggle/AppSwitch.vue'
import MCPFailureAlert from './MCPFailureAlert.vue'
@ -20,7 +20,6 @@ import MCPFailureAlert from './MCPFailureAlert.vue'
const automation = useAutomationMessages()
const settings = useSettingsMessages()
const common = useCommonMessages()
const { copy, copied } = useClipboard()
const statusMessage = computed(
() =>
({
@ -44,14 +43,12 @@ const { restart, chooseRootDirectory } = useMCPSettings()
>
<div class="px-3 py-2.5">
<p class="mb-1 text-xs font-medium text-surface">{{ automation.address }}</p>
<div class="flex items-center justify-between gap-2">
<code class="min-w-0 select-all break-all text-xs text-surface">{{
mcpRuntime.endpoint
}}</code>
<AppButton size="xs" variant="link" @click="copy(mcpRuntime.endpoint)">{{
copied ? common.copied : common.copy
}}</AppButton>
</div>
<AppCopyField
look="plain"
:value="mcpRuntime.endpoint"
:copy-label="common.copy"
:copied-label="common.copied"
/>
</div>
<SettingsRow v-if="mcpRuntime.version" :label="automation.version"
><code class="text-xs text-surface">{{ mcpRuntime.version }}</code></SettingsRow

View file

@ -1,21 +1,21 @@
<script setup lang="ts">
import { computed, ref, useTemplateRef, watch } from 'vue'
import { computed, ref, useTemplateRef } from 'vue'
import type { AIProviderID } from '@open-pencil/core/constants'
import { useI18n } from '@open-pencil/vue'
import { useOnboardingAgents } from '@/app/ai/models/settings/onboarding/agents'
import { usePiSetup } from '@/app/ai/agents/setup'
import { useModelProfileFeedback } from '@/app/ai/models/settings/profile-editor/feedback'
import { useModelProfileEditor } from '@/app/ai/models/settings/profile-editor/use'
import { thinkingLevelOptions as buildThinkingLevelOptions } from '@/app/ai/models/thinking'
import { useSettingsFormGuard } from '@/app/settings/navigation/use'
import ProviderConnectionTestButton from '@/components/chat/ProviderConnectionTestButton.vue'
import AISetupPi from '@/components/settings/ai-setup/AISetupPi.vue'
import PiSetup from '@/components/settings/agents/PiSetup.vue'
import { focusInvalidField } from '@/components/settings/layout/focus'
import SettingsPage from '@/components/settings/layout/SettingsPage.vue'
import SettingsSaveFeedback from '@/components/settings/layout/SettingsSaveFeedback.vue'
import SettingsSection from '@/components/settings/layout/SettingsSection.vue'
import ProviderSelect from '@/components/settings/provider-select/ProviderSelect.vue'
import ProviderConnectionTestButton from '@/components/settings/provider/ProviderConnectionTestButton.vue'
import ProviderSettingsField from '@/components/settings/provider/ProviderSettingsField.vue'
import ProviderSettingsInput from '@/components/settings/provider/ProviderSettingsInput.vue'
import ProviderSettingsKeyField from '@/components/settings/provider/ProviderSettingsKeyField.vue'
@ -58,16 +58,10 @@ const {
clearKey,
testConnection: runConnectionTest
} = profile
const piAgents = useOnboardingAgents()
const piSetup = computed(() => {
const setup = isHarness.value ? piAgents.piSetup('harness:pi') : undefined
// Pi runs only in the desktop app, where its companion can be checked.
return setup?.supported ? setup : undefined
})
const pi = usePiSetup(isHarness)
const customModelPlaceholder = computed(() =>
isHarness.value ? (piSetup.value?.defaultModel ?? 'provider/model') : 'e.g. llama-3.3-70b'
isHarness.value ? (pi.state.value?.defaultModel ?? 'provider/model') : 'e.g. llama-3.3-70b'
)
watch(isHarness, (harness) => harness && piAgents.refreshAgents(), { immediate: true })
const feedback = useModelProfileFeedback(profile, keyInput, settings)
const { errors: fieldErrors } = feedback
const busy = computed(() => saving.value || connectionTestStatus.value === 'testing')
@ -145,12 +139,12 @@ async function remove() {
/>
</ProviderSettingsField>
<div v-if="piSetup" class="flex flex-col gap-2" data-test-id="settings-pi-setup">
<AISetupPi
:setup="piSetup"
@check="piAgents.refreshAgents()"
@install-companion="piAgents.installAgent('harness:pi')"
@install-bridge="piAgents.setupCanvasBridge()"
<div v-if="pi.state.value" class="flex flex-col gap-2" data-test-id="settings-pi-setup">
<PiSetup
:setup="pi.state.value"
@check="pi.refresh()"
@install-companion="pi.installCompanion()"
@install-bridge="pi.installBridge()"
/>
</div>

View file

@ -4,34 +4,17 @@ import { computed } from 'vue'
import { useI18n } from '@open-pencil/vue'
import { useModelRoleAssignments } from '@/app/ai/models/settings/assignments'
import { AI_MODEL_ROLES } from '@/app/ai/models/types'
import SettingsGroup from '@/components/settings/layout/SettingsGroup.vue'
import SettingsRow from '@/components/settings/layout/SettingsRow.vue'
import AppSelect from '@/components/ui/select/AppSelect.vue'
import { useModelRoleLabels } from './role-labels'
const { ai } = useI18n()
const roleDefinitions = computed(() => [
{
role: 'design' as const,
label: ai.value.modelRoleDesign,
description: ai.value.modelRoleDesignDescription
},
{
role: 'review' as const,
label: ai.value.modelRoleReview,
description: ai.value.modelRoleReviewDescription
},
{
role: 'fast' as const,
label: ai.value.modelRoleFast,
description: ai.value.modelRoleFastDescription
},
{
role: 'vision' as const,
label: ai.value.modelRoleVision,
description: ai.value.modelRoleVisionDescription
}
])
const roleLabel = useModelRoleLabels()
const roleDefinitions = computed(() => AI_MODEL_ROLES.map((role) => ({ role, ...roleLabel(role) })))
const { assignmentValue, optionsForRole, updateAssignment } = useModelRoleAssignments(ai)
</script>

View file

@ -0,0 +1,18 @@
import { useI18n } from '@open-pencil/vue'
import type { AIModelRole } from '@/app/ai/models/types'
/** The name and explanation of each model role, shared by model settings and guided setup. */
export function useModelRoleLabels() {
const { ai } = useI18n()
return (role: AIModelRole): { label: string; description: string } => {
const roles = {
design: [ai.value.modelRoleDesign, ai.value.modelRoleDesignDescription],
review: [ai.value.modelRoleReview, ai.value.modelRoleReviewDescription],
fast: [ai.value.modelRoleFast, ai.value.modelRoleFastDescription],
vision: [ai.value.modelRoleVision, ai.value.modelRoleVisionDescription]
} as const
const [label, description] = roles[role]
return { label, description }
}
}

View file

@ -0,0 +1,32 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import AppCopyField from './AppCopyField.vue'
interface Args {
value: string
copyLabel: string
copiedLabel: string
look?: 'command' | 'plain'
}
const meta = {
title: 'Design System/Inputs/Copy Field',
args: {
value: 'npm i -g @open-pencil/mcp@0.15.1',
copyLabel: 'Copy',
copiedLabel: 'Copied'
},
render: (args) => ({
components: { AppCopyField },
setup: () => ({ args }),
template: '<div class="w-96 bg-panel p-6"><AppCopyField v-bind="args" /></div>'
})
} satisfies Meta<Args>
export default meta
type Story = StoryObj<typeof meta>
export const Command: Story = {}
export const Plain: Story = {
args: { value: 'http://127.0.0.1:7600/mcp', look: 'plain' }
}

View file

@ -0,0 +1,39 @@
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { tv, type VariantProps } from 'tailwind-variants'
import { computed } from 'vue'
import AppButton from '@/components/ui/button/AppButton.vue'
import type { ComponentUI } from '@/components/ui/types'
import theme from '@/theme/input/copy-field'
const copyField = tv(theme)
type CopyFieldVariants = VariantProps<typeof copyField>
/** Read-only text with a button that copies it and briefly confirms. */
const {
value,
copyLabel,
copiedLabel,
look = 'command',
ui
} = defineProps<{
value: string
copyLabel: string
copiedLabel: string
look?: NonNullable<CopyFieldVariants['look']>
ui?: ComponentUI<typeof theme>
}>()
const { copy, copied } = useClipboard({ copiedDuring: 1500 })
const cls = computed(() => copyField({ look }))
</script>
<template>
<div data-slot="copy-field" :data-look="look" :class="cls.root({ class: ui?.root })">
<code :class="cls.value({ class: ui?.value })">{{ value }}</code>
<AppButton size="xs" :variant="look === 'plain' ? 'link' : 'ghost'" @click="copy(value)">
{{ copied ? copiedLabel : copyLabel }}
</AppButton>
</div>
</template>

View file

@ -1,15 +1,23 @@
<script setup lang="ts">
import { CheckboxIndicator, CheckboxRoot } from 'reka-ui'
/** Named by `ariaLabel`, or by visible text through `ariaLabelledby`. */
const {
modelValue = false,
ariaLabel,
ariaLabelledby,
ariaDescribedby,
disabled = false
} = defineProps<{
modelValue?: boolean | 'indeterminate'
ariaLabel: string
disabled?: boolean
}>()
} = defineProps<
{
modelValue?: boolean | 'indeterminate'
disabled?: boolean
ariaDescribedby?: string
} & (
| { ariaLabel: string; ariaLabelledby?: never }
| { ariaLabel?: never; ariaLabelledby: string }
)
>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
</script>
@ -18,6 +26,8 @@ const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
<CheckboxRoot
:model-value="modelValue"
:aria-label="ariaLabel"
:aria-labelledby="ariaLabelledby"
:aria-describedby="ariaDescribedby"
:disabled="disabled"
class="flex size-4 shrink-0 items-center justify-center rounded border border-border bg-panel-field text-white outline-none transition-colors hover:border-accent/60 data-[state=checked]:border-accent data-[state=checked]:bg-accent data-[state=indeterminate]:border-accent data-[state=indeterminate]:bg-accent focus-visible:ring-2 focus-visible:ring-accent/40 disabled:opacity-50"
@update:model-value="(value) => emit('update:modelValue', value === true)"

View file

@ -0,0 +1,40 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { expect, userEvent, within } from 'storybook/test'
import { ref } from 'vue'
import AppCheckboxCard from './AppCheckboxCard.vue'
interface Args {
label: string
description?: string
disabled?: boolean
}
const meta = {
title: 'Design System/Inputs/Checkbox card',
args: {
label: 'Review designs visually',
description: 'A model that reads images checks the result against your request.'
},
render: (args) => ({
components: { AppCheckboxCard },
setup: () => ({ args, checked: ref(false) }),
template:
'<div class="flex w-80 max-w-full flex-col gap-2 bg-panel p-4"><AppCheckboxCard v-bind="args" v-model="checked" /><p class="text-xs text-muted">Checked: {{ checked }}</p></div>'
})
} satisfies Meta<Args>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement)
const checkbox = canvas.getByRole('checkbox', { name: args.label })
await expect(checkbox).toHaveAccessibleDescription(args.description ?? '')
await userEvent.click(canvas.getByText(args.label))
await expect(checkbox).toBeChecked()
}
}
export const WithoutDescription: Story = { args: { description: undefined } }
export const Disabled: Story = { args: { disabled: true } }

View file

@ -0,0 +1,47 @@
<script setup lang="ts">
import { tv } from 'tailwind-variants'
import { useId } from 'vue'
import type { ComponentUI } from '@/components/ui/types'
import theme from '@/theme/toggle/checkbox-card'
import AppCheckbox from './AppCheckbox.vue'
/** A checkbox choice whose whole card toggles it, named by its label and described by its text. */
const {
label,
description,
disabled = false,
ui
} = defineProps<{
label: string
description?: string
disabled?: boolean
ui?: ComponentUI<typeof theme>
}>()
const checked = defineModel<boolean>({ required: true })
const styles = tv(theme)()
const id = useId()
</script>
<template>
<label data-slot="checkbox-card" :class="styles.root({ class: ui?.root })">
<AppCheckbox
v-model="checked"
:disabled="disabled"
:ariaLabelledby="`${id}-label`"
:ariaDescribedby="description ? `${id}-description` : undefined"
/>
<slot name="icon" />
<span :class="styles.text({ class: ui?.text })">
<span :id="`${id}-label`" :class="styles.label({ class: ui?.label })">{{ label }}</span>
<span
v-if="description"
:id="`${id}-description`"
:class="styles.description({ class: ui?.description })"
>
{{ description }}
</span>
</span>
</label>
</template>

View file

@ -1,69 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { expect, userEvent, waitFor, within } from 'storybook/test'
import { ref } from 'vue'
import AppRadioGroup from './AppRadioGroup.vue'
import type { AppRadioOption } from './radio'
interface Args {
label: string
options: AppRadioOption<string>[]
orientation?: 'vertical' | 'horizontal'
}
const options: AppRadioOption<string>[] = [
{ value: 'existing', label: 'Use my existing accounts only' },
{
value: 'metered',
label: 'Include pay-as-you-go options',
description: 'Requests are billed to the API account you connect.'
},
{ value: 'later', label: 'Decide later', disabled: true }
]
const meta = {
title: 'Design System/Inputs/Radio group',
args: { label: 'Spending', options },
render: (args) => ({
components: { AppRadioGroup },
setup: () => ({ args, value: ref('existing') }),
template:
'<div class="w-80 max-w-full"><AppRadioGroup v-bind="args" v-model="value" /><p class="mt-3 text-xs text-muted">Selected: {{ value }}</p></div>'
})
} satisfies Meta<Args>
export default meta
type Story = StoryObj<typeof meta>
export const Vertical: Story = {}
export const Horizontal: Story = {
args: {
orientation: 'horizontal',
options: options.map(({ value, label }) => ({ value, label }))
}
}
export const KeyboardSelection: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
const existing = canvas.getByRole('radio', { name: 'Use my existing accounts only' })
const metered = canvas.getByRole('radio', { name: 'Include pay-as-you-go options' })
await expect(metered).toHaveAccessibleDescription(
'Requests are billed to the API account you connect.'
)
await expect(existing).toBeChecked()
await userEvent.click(existing)
// Reka checks the newly focused radio only while the arrow key is still held, as in a browser.
async function pressArrow(key: string) {
await userEvent.keyboard(`{${key}>}`)
await waitFor(() => expect(canvasElement.ownerDocument.activeElement).toBeChecked())
await userEvent.keyboard(`{/${key}}`)
}
await pressArrow('ArrowDown')
await expect(metered).toBeChecked()
await expect(canvas.getByText('Selected: metered')).toBeVisible()
await pressArrow('ArrowRight')
await expect(existing).toBeChecked()
await pressArrow('ArrowLeft')
await expect(metered).toBeChecked()
await expect(canvas.getByRole('radio', { name: 'Decide later' })).toBeDisabled()
}
}

View file

@ -1,68 +0,0 @@
<script setup lang="ts" generic="T extends string | number">
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot } from 'reka-ui'
import { tv } from 'tailwind-variants'
import { useId } from 'vue'
import theme from '@/theme/toggle/radio'
import type { AppRadioGroupUI, AppRadioOption } from './radio'
const {
label,
labelledby,
options,
orientation,
disabled = false,
ui
} = defineProps<{
label?: string
labelledby?: string
options: AppRadioOption<T>[]
orientation?: 'vertical' | 'horizontal'
disabled?: boolean
ui?: AppRadioGroupUI
}>()
const modelValue = defineModel<T>({ required: true })
const styles = tv(theme)()
const id = useId()
</script>
<template>
<RadioGroupRoot
v-model="modelValue"
:aria-label="labelledby ? undefined : label"
:aria-labelledby="labelledby"
:orientation="orientation"
:disabled="disabled"
:class="styles.root({ class: ui?.root })"
>
<label
v-for="(option, index) in options"
:key="option.value"
:class="styles.option({ class: ui?.option })"
data-slot="radio-option"
>
<RadioGroupItem
:value="option.value"
:disabled="option.disabled"
:aria-labelledby="`${id}-${index}-label`"
:aria-describedby="option.description ? `${id}-${index}-description` : undefined"
:class="styles.item({ class: ui?.item })"
>
<RadioGroupIndicator :class="styles.indicator({ class: ui?.indicator })" />
</RadioGroupItem>
<span :class="styles.text({ class: ui?.text })">
<span :id="`${id}-${index}-label`" :class="styles.label({ class: ui?.label })">{{
option.label
}}</span>
<span
v-if="option.description"
:id="`${id}-${index}-description`"
:class="styles.description({ class: ui?.description })"
>
{{ option.description }}
</span>
</span>
</label>
</RadioGroupRoot>
</template>

View file

@ -1,11 +0,0 @@
import type { ComponentUI } from '@/components/ui/types'
import type { RadioGroupTheme } from '@/theme/toggle/radio'
export type AppRadioGroupUI = ComponentUI<RadioGroupTheme>
export interface AppRadioOption<TValue extends string | number> {
value: TValue
label: string
description?: string
disabled?: boolean
}

View file

@ -0,0 +1,17 @@
export default {
slots: {
root: 'flex min-w-0 items-center justify-between gap-2',
value: 'min-w-0 select-all break-all text-surface'
},
variants: {
look: {
/** A command to run, set apart like a terminal line. */
command: { root: 'rounded bg-input px-2.5 py-1.5', value: 'font-mono text-[11px]' },
/** An address or token inside a settings row. */
plain: { value: 'text-xs' }
}
},
defaultVariants: {
look: 'command' as const
}
}

View file

@ -0,0 +1,15 @@
import { motionStyles } from '@/theme/motion/styles'
/** Setup of a coding agent or Pi and the OpenPencil companions it needs on this computer. */
export default {
slots: {
help: 'text-xs leading-relaxed text-muted',
status: 'flex items-center gap-2 text-xs text-surface',
spinner: `size-3.5 shrink-0 ${motionStyles.spinner}`,
list: 'flex flex-col gap-1.5',
item: 'flex items-center gap-2 text-xs text-surface',
readyIcon: 'size-3.5 shrink-0 text-success',
missingIcon: 'size-3.5 shrink-0 text-muted',
actions: 'flex items-center gap-2'
}
}

View file

@ -12,8 +12,6 @@ export default {
// Reads as a group heading; the gap below lives inside the animated content.
moreTrigger: 'py-1.5 text-[11px] font-semibold text-muted transition-colors hover:text-surface',
moreGroup: 'flex flex-col gap-2 pt-1',
choice:
'flex cursor-pointer items-start gap-3 rounded-md border border-border px-3 py-3 transition-colors hover:bg-hover has-[[data-state=checked]]:border-accent has-[[data-state=checked]]:bg-accent/5 focus-within:ring-2 focus-within:ring-accent/50',
connection: 'flex flex-col gap-3 rounded-md border border-border p-3',
connectionHeading: 'flex items-center gap-2 text-xs font-semibold text-surface',
signIn: 'flex flex-wrap items-center justify-between gap-2 rounded bg-input px-3 py-2',
@ -21,11 +19,6 @@ export default {
signInActions: 'flex items-center gap-2',
signInDetail: 'ml-1 text-muted',
signedInIcon: 'size-3.5 shrink-0 text-success',
missingIcon: 'size-3.5 shrink-0 text-muted',
installList: 'flex flex-col gap-1.5',
installItem: 'flex items-center gap-2 text-xs text-surface',
spinner: `size-3.5 shrink-0 ${motionStyles.spinner}`,
command:
'flex items-center justify-between gap-2 rounded bg-input px-2.5 py-1.5 font-mono text-[11px] text-surface'
spinner: `size-3.5 shrink-0 ${motionStyles.spinner}`
}
}

View file

@ -0,0 +1,8 @@
export default {
slots: {
root: 'flex cursor-pointer items-start gap-3 rounded-md border border-border px-3 py-3 text-xs text-surface transition-colors hover:bg-hover has-[[data-state=checked]]:border-accent has-[[data-state=checked]]:bg-accent/5 focus-within:ring-2 focus-within:ring-accent/50 has-[[data-disabled]]:cursor-not-allowed has-[[data-disabled]]:opacity-50',
text: 'flex min-w-0 flex-col',
label: 'font-medium',
description: 'mt-1 text-muted'
}
}

View file

@ -1,15 +0,0 @@
const radioGroupTheme = {
slots: {
root: 'flex flex-col gap-2 data-[orientation=horizontal]:flex-row data-[orientation=horizontal]:flex-wrap',
option:
'flex cursor-pointer items-start gap-2 text-xs text-surface has-[[data-disabled]]:cursor-not-allowed has-[[data-disabled]]:opacity-50',
item: 'mt-px flex size-4 shrink-0 items-center justify-center rounded-full border border-border bg-panel-field outline-none transition-colors hover:border-accent/60 focus-visible:ring-2 focus-visible:ring-accent/40 data-[state=checked]:border-accent data-[state=checked]:bg-accent data-[disabled]:pointer-events-none',
indicator: 'size-1.5 rounded-full bg-white',
text: 'flex min-w-0 flex-col',
label: 'font-medium',
description: 'mt-1 text-muted'
}
}
export type RadioGroupTheme = typeof radioGroupTheme
export default radioGroupTheme

View file

@ -0,0 +1,218 @@
import { describe, expect, mock, test } from 'bun:test'
import { nextTick, ref } from 'vue'
import { ACP_AGENTS, type ACPAgentDef } from '@open-pencil/core/constants'
import { createAgentDiscovery, type DetectedAgent } from '@/app/ai/agents/discovery'
import type { AgentLookup } from '@/app/ai/agents/native'
import {
agentSetupView,
piSetupView,
useAgentSetup,
usePiSetup,
type AgentSetupState,
type PiSetupState
} from '@/app/ai/agents/setup'
function lookup(...commands: string[]): AgentLookup {
return {
searchPath: '/test/bin',
versions: {},
executables: Object.fromEntries(commands.map((command) => [command, `/test/bin/${command}`]))
}
}
describe('useAgentSetup', () => {
test('installs the Harness companion for Pi and reads Pi’s default model', async () => {
let installed = false
const installHarness = mock(async () => {
installed = true
})
const discovery = createAgentDiscovery({
enabled: true,
lookup: async () => lookup('npm', ...(installed ? ['openpencil-harness'] : [])),
install: async () => undefined,
installHarness
})
const agents = useAgentSetup(discovery, async () => ({
agentDir: '/home/test/.pi/agent',
defaultModel: 'openai-codex/gpt-5.6',
signedIn: true
}))
await agents.refreshAgents()
expect(agents.piSetup('harness:pi')).toMatchObject({
companion: false,
npm: true,
defaultModel: 'openai-codex/gpt-5.6'
})
await agents.installAgent('harness:pi')
expect(installHarness).toHaveBeenCalledTimes(1)
expect(agents.piSetup('harness:pi')?.companion).toBe(true)
expect(agents.piSetup('acp:codex')).toBeUndefined()
})
})
function agent(id: string): ACPAgentDef {
const definition = ACP_AGENTS.find((candidate) => candidate.id === id)
if (!definition) throw new Error(`Unknown agent ${id}`)
return definition
}
function agentState(overrides: Partial<AgentSetupState> = {}): AgentSetupState {
return {
supported: true,
checked: true,
scanning: false,
detected: null,
bridge: true,
bridgeOutdated: false,
bridgeCommand: 'npm i -g @open-pencil/mcp@1.0.0',
npm: true,
installingAgent: false,
installingBridge: false,
error: null,
...overrides
}
}
function piState(overrides: Partial<PiSetupState> = {}): PiSetupState {
return {
supported: true,
checked: true,
scanning: false,
companion: true,
companionOutdated: false,
companionCommand: 'npm i -g @open-pencil/harness@1.0.0',
bridge: true,
bridgeOutdated: false,
bridgeCommand: 'npm i -g @open-pencil/mcp@1.0.0',
npm: true,
installingCompanion: false,
installingBridge: false,
defaultModel: null,
error: null,
...overrides
}
}
describe('agentSetupView', () => {
const claude = agent('claude-code')
const needsAdapter: DetectedAgent = {
definition: claude,
status: 'needs-adapter',
cliPath: '/test/bin/claude',
adapterPath: null
}
test('installs the adapter and updates an outdated MCP server with one click', () => {
const view = agentSetupView(
claude,
agentState({ detected: needsAdapter, bridgeOutdated: true })
)
expect(view.canInstallAdapter).toBe(true)
expect(view.bridge).toMatchObject({ ready: false, action: 'update' })
expect(view.manualAgentCommand).toBeNull()
expect(view.manualBridgeCommand).toBeNull()
})
test('falls back to commands by hand without npm', () => {
const view = agentSetupView(
claude,
agentState({ detected: needsAdapter, bridge: false, npm: false })
)
expect(view.canInstallAdapter).toBe(false)
expect(view.bridge.action).toBeNull()
expect(view.problem).toBe('needs-npm')
expect(view.manualAgentCommand).toBe(claude.installCommand ?? null)
expect(view.manualBridgeCommand).toBe('npm i -g @open-pencil/mcp@1.0.0')
})
test('offers the command of an agent without an adapter package to install by hand', () => {
const gemini = agent('gemini-cli')
const view = agentSetupView(
gemini,
agentState({
detected: { definition: gemini, status: 'not-installed', cliPath: null, adapterPath: null }
})
)
expect(view.manualAgentCommand).toBe(gemini.installCommand ?? null)
})
test('shows the commands after a failed installation', () => {
const view = agentSetupView(
claude,
agentState({ detected: needsAdapter, bridge: false, error: 'canvas-install' })
)
expect(view.problem).toBe('install-failed')
expect(view.manualBridgeCommand).toBe('npm i -g @open-pencil/mcp@1.0.0')
})
test('gives only instructions where agents cannot run', () => {
const view = agentSetupView(claude, agentState({ supported: false, bridge: false }))
expect(view.manualAgentCommand).toBe(claude.installCommand ?? null)
expect(view.manualBridgeCommand).toMatch(/@open-pencil\/mcp/)
})
})
describe('piSetupView', () => {
test('is ready with a matching companion and MCP server', () => {
const view = piSetupView(piState())
expect(view.companion).toMatchObject({ ready: true, action: null })
expect(view.problem).toBeNull()
expect(view.manualCommands).toEqual([])
})
test('asks for npm and lists the missing commands only after a check', () => {
const missing = { companion: false, npm: false }
expect(piSetupView(piState({ ...missing, checked: false })).problem).toBeNull()
const view = piSetupView(piState(missing))
expect(view.problem).toBe('needs-npm')
expect(view.manualCommands).toEqual(['npm i -g @open-pencil/harness@1.0.0'])
})
test('reports a failed companion installation, which the agent panel ignores', () => {
const failed = { error: 'harness-install' as const }
expect(piSetupView(piState({ ...failed, companion: false })).problem).toBe('install-failed')
expect(agentSetupView(agent('codex'), agentState(failed)).problem).toBeNull()
})
})
describe('usePiSetup', () => {
test('checks Pi each time the editor switches to it', async () => {
const scan = mock(async () => lookup('npm', 'openpencil-harness'))
const discovery = createAgentDiscovery({
enabled: true,
lookup: scan,
install: async () => undefined
})
const active = ref(false)
const pi = usePiSetup(
active,
useAgentSetup(discovery, async () => null)
)
expect(pi.state.value).toBeUndefined()
expect(scan).not.toHaveBeenCalled()
active.value = true
await nextTick()
expect(scan).toHaveBeenCalledTimes(1)
await scan.mock.results[0]?.value
await nextTick()
expect(pi.state.value?.companion).toBe(true)
})
test('has nothing to check outside the desktop app', () => {
const discovery = createAgentDiscovery({
enabled: false,
lookup: async () => lookup(),
install: async () => undefined
})
expect(
usePiSetup(
true,
useAgentSetup(discovery, async () => null)
).state.value
).toBeUndefined()
})
})

View file

@ -1,43 +0,0 @@
import { describe, expect, mock, test } from 'bun:test'
import { createAgentDiscovery } from '@/app/ai/agents/discovery'
import type { AgentLookup } from '@/app/ai/agents/native'
import { useOnboardingAgents } from '@/app/ai/models/settings/onboarding/agents'
function lookup(...commands: string[]): AgentLookup {
return {
searchPath: '/test/bin',
versions: {},
executables: Object.fromEntries(commands.map((command) => [command, `/test/bin/${command}`]))
}
}
describe('useOnboardingAgents', () => {
test('installs the Harness companion for Pi and reads Pi’s default model', async () => {
let installed = false
const installHarness = mock(async () => {
installed = true
})
const discovery = createAgentDiscovery({
enabled: true,
lookup: async () => lookup('npm', ...(installed ? ['openpencil-harness'] : [])),
install: async () => undefined,
installHarness
})
const agents = useOnboardingAgents(discovery, async () => ({
agentDir: '/home/test/.pi/agent',
defaultModel: 'openai-codex/gpt-5.6',
signedIn: true
}))
await agents.refreshAgents()
expect(agents.piSetup('harness:pi')).toMatchObject({
companion: false,
npm: true,
defaultModel: 'openai-codex/gpt-5.6'
})
await agents.installAgent('harness:pi')
expect(installHarness).toHaveBeenCalledTimes(1)
expect(agents.piSetup('harness:pi')?.companion).toBe(true)
expect(agents.piSetup('acp:codex')).toBeUndefined()
})
})