/** * Overlay modal for editing Superpowers settings. Sits on top of the kanban * without unmounting it — matches the LogModal / NewIssueModal pattern: * - Backdrop click is a no-op (only ESC closes, and only when `canCancel`). * - Two-column layout: vertical group rail on the left, fields on the right. * * Replaces the old full-screen `SetupForm.tsx`. */ import type { ReactElement } from 'react' import type { ClaudeProfile } from '../hooks/useIssues' import { X } from 'lucide-react' import { useEffect, useState } from 'react' import { SelectMenu } from './ui/select-menu' type GroupKey = 'auth' | 'network' | 'youtrack' | 'prompts' | 'hooks' interface SubmitValues { host: string token: string webhookPort: number webhookSecret: string brainstormPrompt: string implementPlanPrompt: string autoReview: boolean reviewPrompt: string testPrompt: string prototypePrompt: string devBranch: string releaseBranchPrefix: string jenkinsJob: string jenkinsProdHost: string worktreeDirectory: string worktreeOpenWith: 'vscode' | 'pycharm' pycharmCommand: string worktreePostCreateScript: string worktreePreRemoveScript: string implTabPreCreateScript: string implTabPostCloseScript: string splitNewTerminals: boolean defaultBrainstormProfilePath: string defaultImplementProfilePath: string defaultTestProfilePath: string defaultPrototypeProfilePath: string defaultAskProfilePath: string defaultRewriteProfilePath: string conflictResolutionProfilePath: string commitProfilePath: string prDiffSummaryProfilePath: string codexModel: string codexReasoningEffort: string profilesDirectory: string youtrackBaseUrl: string youtrackProjectShortName: string youtrackCloseCommand: string youtrackToken: string } /** Project option for the YouTrack project dropdown. */ export interface YouTrackProjectOption { id: string name: string shortName: string } export interface YouTrackProjectsState { status: 'idle' | 'loading' | 'ready' | 'error' projects: YouTrackProjectOption[] error?: string } export interface SettingsModalProps { open: boolean host: string errorMessage?: string canCancel?: boolean initialTokenSaved: boolean initialWebhookPort: number initialWebhookSecret: string initialBrainstormPrompt: string initialImplementPlanPrompt: string initialAutoReview: boolean initialReviewPrompt: string initialTestPrompt: string initialPrototypePrompt: string initialDevBranch: string initialReleaseBranchPrefix: string initialJenkinsJob: string initialJenkinsProdHost: string initialWorktreeDirectory: string initialWorktreeOpenWith: 'vscode' | 'pycharm' initialPycharmCommand: string initialWorktreePostCreateScript: string initialWorktreePreRemoveScript: string initialImplTabPreCreateScript: string initialImplTabPostCloseScript: string initialSplitNewTerminals: boolean initialDefaultBrainstormProfilePath: string initialDefaultImplementProfilePath: string initialDefaultTestProfilePath: string initialDefaultPrototypeProfilePath: string initialDefaultAskProfilePath: string initialDefaultRewriteProfilePath: string initialConflictResolutionProfilePath: string initialCommitProfilePath: string initialPrDiffSummaryProfilePath: string initialCodexModel: string initialCodexReasoningEffort: string initialProfilesDirectory: string initialYoutrackBaseUrl: string initialYoutrackProjectShortName: string initialYoutrackCloseCommand: string initialYoutrackTokenSaved: boolean profiles: ClaudeProfile[] youtrackProjects: YouTrackProjectsState onListYouTrackProjects: (baseUrl: string, token: string) => void onSubmit: (values: SubmitValues) => void onCancel?: () => void } const inputClass = 'w-full rounded border border-[var(--vscode-input-border,transparent)] bg-[var(--vscode-input-background)] px-2 py-1 text-xs text-[var(--vscode-input-foreground)] outline-none focus:border-[var(--vscode-focusBorder)]' const promptTextareaClass = `${inputClass} field-sizing-content min-h-16 resize-y font-mono` interface FieldProps { label: string hint?: React.ReactNode error?: string children: React.ReactNode } function Field({ label, hint, error, children }: FieldProps) { return ( ) } const GROUPS: Array<{ key: GroupKey, label: string }> = [ { key: 'auth', label: '认证' }, { key: 'network', label: '网络' }, { key: 'youtrack', label: 'YouTrack' }, { key: 'prompts', label: '提示词' }, { key: 'hooks', label: '钩子' }, ] type ErrorKey = 'host' | 'token' | 'webhookPort' /** * Map a field name to the group it lives in, so we can auto-switch to the * first invalid field's group on save. */ const FIELD_TO_GROUP: Record = { host: 'auth', token: 'auth', webhookPort: 'network', } export function SettingsModal({ open, host: initialHost, errorMessage, canCancel, initialTokenSaved, initialWebhookPort, initialWebhookSecret, initialBrainstormPrompt, initialImplementPlanPrompt, initialAutoReview, initialReviewPrompt, initialTestPrompt, initialPrototypePrompt, initialDevBranch, initialReleaseBranchPrefix, initialJenkinsJob, initialJenkinsProdHost, initialWorktreeDirectory, initialWorktreeOpenWith, initialPycharmCommand, initialWorktreePostCreateScript, initialWorktreePreRemoveScript, initialImplTabPreCreateScript, initialImplTabPostCloseScript, initialSplitNewTerminals, initialDefaultBrainstormProfilePath, initialDefaultImplementProfilePath, initialDefaultTestProfilePath, initialDefaultPrototypeProfilePath, initialDefaultAskProfilePath, initialDefaultRewriteProfilePath, initialConflictResolutionProfilePath, initialCommitProfilePath, initialPrDiffSummaryProfilePath, initialCodexModel, initialCodexReasoningEffort, initialProfilesDirectory, initialYoutrackBaseUrl, initialYoutrackProjectShortName, initialYoutrackCloseCommand, initialYoutrackTokenSaved, profiles, youtrackProjects, onListYouTrackProjects, onSubmit, onCancel, }: SettingsModalProps): ReactElement | null { const [activeGroup, setActiveGroup] = useState('auth') const [host, setHost] = useState(initialHost) const [token, setToken] = useState('') const [webhookPort, setWebhookPort] = useState(String(initialWebhookPort)) const [webhookSecret, setWebhookSecret] = useState(initialWebhookSecret) const [brainstormPrompt, setBrainstormPrompt] = useState(initialBrainstormPrompt) const [implementPlanPrompt, setImplementPlanPrompt] = useState(initialImplementPlanPrompt) const [autoReview, setAutoReview] = useState(initialAutoReview) const [reviewPrompt, setReviewPrompt] = useState(initialReviewPrompt) const [testPrompt, setTestPrompt] = useState(initialTestPrompt) const [prototypePrompt, setPrototypePrompt] = useState(initialPrototypePrompt) const [devBranch, setDevBranch] = useState(initialDevBranch) const [releaseBranchPrefix, setReleaseBranchPrefix] = useState(initialReleaseBranchPrefix) const [jenkinsJob, setJenkinsJob] = useState(initialJenkinsJob) const [jenkinsProdHost, setJenkinsProdHost] = useState(initialJenkinsProdHost) const [worktreeDirectory, setWorktreeDirectory] = useState(initialWorktreeDirectory) const [worktreeOpenWith, setWorktreeOpenWith] = useState(initialWorktreeOpenWith) const [pycharmCommand, setPycharmCommand] = useState(initialPycharmCommand) const [worktreePostCreateScript, setWorktreePostCreateScript] = useState(initialWorktreePostCreateScript) const [worktreePreRemoveScript, setWorktreePreRemoveScript] = useState(initialWorktreePreRemoveScript) const [implTabPreCreateScript, setImplTabPreCreateScript] = useState(initialImplTabPreCreateScript) const [implTabPostCloseScript, setImplTabPostCloseScript] = useState(initialImplTabPostCloseScript) const [splitNewTerminals, setSplitNewTerminals] = useState(initialSplitNewTerminals) const [defaultBrainstormProfilePath, setDefaultBrainstormProfilePath] = useState(initialDefaultBrainstormProfilePath) const [defaultImplementProfilePath, setDefaultImplementProfilePath] = useState(initialDefaultImplementProfilePath) const [defaultTestProfilePath, setDefaultTestProfilePath] = useState(initialDefaultTestProfilePath) const [defaultPrototypeProfilePath, setDefaultPrototypeProfilePath] = useState(initialDefaultPrototypeProfilePath) const [defaultAskProfilePath, setDefaultAskProfilePath] = useState(initialDefaultAskProfilePath) const [defaultRewriteProfilePath, setDefaultRewriteProfilePath] = useState(initialDefaultRewriteProfilePath) const [conflictResolutionProfilePath, setConflictResolutionProfilePath] = useState(initialConflictResolutionProfilePath) const [commitProfilePath, setCommitProfilePath] = useState(initialCommitProfilePath) const [prDiffSummaryProfilePath, setPrDiffSummaryProfilePath] = useState(initialPrDiffSummaryProfilePath) const [codexModel, setCodexModel] = useState(initialCodexModel) const [codexReasoningEffort, setCodexReasoningEffort] = useState(initialCodexReasoningEffort) const [profilesDirectory, setProfilesDirectory] = useState(initialProfilesDirectory) const [youtrackBaseUrl, setYoutrackBaseUrl] = useState(initialYoutrackBaseUrl) const [youtrackProjectShortName, setYoutrackProjectShortName] = useState(initialYoutrackProjectShortName) const [youtrackCloseCommand, setYoutrackCloseCommand] = useState(initialYoutrackCloseCommand) const [youtrackToken, setYoutrackToken] = useState('') const [errors, setErrors] = useState>>({}) // Reset local state whenever the modal opens with fresh server values. // We intentionally don't depend on initialBrainstormPrompt/initialImplementPlanPrompt // to mid-edit; this effect just primes form state on each (re)open. useEffect(() => { if (!open) return setActiveGroup('auth') setHost(initialHost) setToken('') setWebhookPort(String(initialWebhookPort)) setWebhookSecret(initialWebhookSecret) setBrainstormPrompt(initialBrainstormPrompt) setImplementPlanPrompt(initialImplementPlanPrompt) setAutoReview(initialAutoReview) setReviewPrompt(initialReviewPrompt) setTestPrompt(initialTestPrompt) setPrototypePrompt(initialPrototypePrompt) setDevBranch(initialDevBranch) setReleaseBranchPrefix(initialReleaseBranchPrefix) setJenkinsJob(initialJenkinsJob) setJenkinsProdHost(initialJenkinsProdHost) setWorktreeDirectory(initialWorktreeDirectory) setWorktreeOpenWith(initialWorktreeOpenWith) setPycharmCommand(initialPycharmCommand) setWorktreePostCreateScript(initialWorktreePostCreateScript) setWorktreePreRemoveScript(initialWorktreePreRemoveScript) setImplTabPreCreateScript(initialImplTabPreCreateScript) setImplTabPostCloseScript(initialImplTabPostCloseScript) setSplitNewTerminals(initialSplitNewTerminals) setDefaultBrainstormProfilePath(initialDefaultBrainstormProfilePath) setDefaultImplementProfilePath(initialDefaultImplementProfilePath) setDefaultTestProfilePath(initialDefaultTestProfilePath) setDefaultPrototypeProfilePath(initialDefaultPrototypeProfilePath) setDefaultAskProfilePath(initialDefaultAskProfilePath) setDefaultRewriteProfilePath(initialDefaultRewriteProfilePath) setConflictResolutionProfilePath(initialConflictResolutionProfilePath) setCommitProfilePath(initialCommitProfilePath) setPrDiffSummaryProfilePath(initialPrDiffSummaryProfilePath) setCodexModel(initialCodexModel) setCodexReasoningEffort(initialCodexReasoningEffort) setProfilesDirectory(initialProfilesDirectory) setYoutrackBaseUrl(initialYoutrackBaseUrl) setYoutrackProjectShortName(initialYoutrackProjectShortName) setYoutrackCloseCommand(initialYoutrackCloseCommand) setYoutrackToken('') setErrors({}) }, [open]) // ESC closes when allowed. useEffect(() => { if (!open) return function onKey(e: KeyboardEvent): void { if (e.key === 'Escape' && canCancel && onCancel) { e.preventDefault() onCancel() } } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) }, [open, canCancel, onCancel]) if (!open) return null const trimmedHost = host.trim() const tokenPageUrl = trimmedHost ? `https://${trimmedHost}/user/settings/applications` : '' function handleSubmit(event: React.FormEvent): void { event.preventDefault() const nextErrors: Partial> = {} const trimmedToken = token.trim() if (!trimmedHost) nextErrors.host = 'Host 不能为空' // When a token is already saved server-side, leaving the field blank // means "keep the existing token" — don't flag it as an error. Only // require a new token when nothing is saved yet. if (!trimmedToken && !initialTokenSaved) nextErrors.token = 'Token 不能为空' const portNum = Number.parseInt(webhookPort, 10) if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) nextErrors.webhookPort = '端口必须是 1–65535 的整数' if (Object.keys(nextErrors).length > 0) { setErrors(nextErrors) // Auto-jump to the group containing the first invalid field, so the // inline error is visible without the user hunting for it. const order: ErrorKey[] = ['host', 'token', 'webhookPort'] for (const key of order) { if (nextErrors[key]) { setActiveGroup(FIELD_TO_GROUP[key]) break } } return } setErrors({}) onSubmit({ host: trimmedHost, // Pass raw token (may be empty string when keeping existing). The // extension side detects empty + existing-saved as "preserve". token: trimmedToken, webhookPort: portNum, webhookSecret: webhookSecret.trim(), brainstormPrompt, implementPlanPrompt, autoReview, reviewPrompt, testPrompt, prototypePrompt, devBranch: devBranch.trim(), releaseBranchPrefix: releaseBranchPrefix.trim(), jenkinsJob: jenkinsJob.trim(), jenkinsProdHost: jenkinsProdHost.trim(), // worktree 目录模板:trim 后留空 = 用默认模板(getSettings 读时回退) worktreeDirectory: worktreeDirectory.trim(), worktreeOpenWith, // pycharm 启动命令:trim 后留空 = 用默认 `pycharm`(getSettings 读时回退) pycharmCommand: pycharmCommand.trim(), // 钩子路径保持原样 trim(前后空格无意义;空串 = 用默认 .spx/*.sh) worktreePostCreateScript: worktreePostCreateScript.trim(), worktreePreRemoveScript: worktreePreRemoveScript.trim(), implTabPreCreateScript: implTabPreCreateScript.trim(), implTabPostCloseScript: implTabPostCloseScript.trim(), splitNewTerminals, // 四个会话默认 profile:空串 = 落到 DEFAULT_PROFILE_PATH defaultBrainstormProfilePath: defaultBrainstormProfilePath.trim(), defaultImplementProfilePath: defaultImplementProfilePath.trim(), defaultTestProfilePath: defaultTestProfilePath.trim(), defaultPrototypeProfilePath: defaultPrototypeProfilePath.trim(), defaultAskProfilePath: defaultAskProfilePath.trim(), defaultRewriteProfilePath: defaultRewriteProfilePath.trim(), // 冲突解决 profile:空串 = 用 DEFAULT_PROFILE_PATH conflictResolutionProfilePath: conflictResolutionProfilePath.trim(), // 提交代码 profile:空串 = 未配置 → 提交严格失败 commitProfilePath: commitProfilePath.trim(), prDiffSummaryProfilePath: prDiffSummaryProfilePath.trim(), // codex 模型 / 思考级别:trim 后留空 = 用 codex config.toml 默认 codexModel: codexModel.trim(), codexReasoningEffort: codexReasoningEffort.trim(), // profiles 目录:trim 后留空 = 自动探测 profilesDirectory: profilesDirectory.trim(), youtrackBaseUrl: youtrackBaseUrl.trim(), youtrackProjectShortName: youtrackProjectShortName.trim(), youtrackCloseCommand: youtrackCloseCommand.trim(), // Raw (may be empty = keep existing), mirroring the gitea token rule. youtrackToken: youtrackToken.trim(), }) } return (
{/* Header */}

设置

{canCancel && onCancel && ( )}
{/* Body */}
{/* Left rail */}
{GROUPS.map((g) => { const active = g.key === activeGroup return ( ) })}
{/* Right pane */}
{errorMessage && (
{errorMessage}
)} {activeGroup === 'auth' && ( <> setHost(e.target.value)} placeholder="gitea.example.com" className={inputClass} /> 在 {' '} {tokenPageUrl ? ( {tokenPageUrl} ) : 填写 Host 后这里会显示生成 token 的链接} {' '} 生成一个 token {initialTokenSaved && ( <>
已保存,留空则保留 )} )} > setToken(e.target.value)} placeholder={initialTokenSaved ? '••••••••' : '例如 1a2b3c4d…'} className={inputClass} />
日常推送的分支,留空默认 {' '} main 。发布按钮从这条分支切发布分支、统计未发布提交。 )} > setDevBranch(e.target.value)} placeholder="main" className={inputClass} /> setReleaseBranchPrefix(e.target.value)} placeholder="release/" className={inputClass} /> 留空则不显示「部署到正式服」;触发时执行 {' '} opencli jks build <任务名> -p REF=… -p REMOTE_HOST=… )} > setJenkinsJob(e.target.value)} placeholder="留空 = 不提供部署" className={inputClass} /> setJenkinsProdHost(e.target.value)} placeholder="正式服" className={inputClass} /> 新建工单 / 续聊的头脑风暴会话默认用此 profile;工单详情面板里的「头脑风暴配置文件」优先级更高。留空用 official。 )} > ({ value: p.path, label: p.name })), ...(defaultBrainstormProfilePath && !profiles.some(p => p.path === defaultBrainstormProfilePath) ? [{ value: defaultBrainstormProfilePath, label: `自定义:${defaultBrainstormProfilePath}` }] : []), ]} /> 实施会话默认用此 profile;工单详情面板里的「实施配置文件」优先级更高。留空用 official。 )} > ({ value: p.path, label: p.name })), ...(defaultImplementProfilePath && !profiles.some(p => p.path === defaultImplementProfilePath) ? [{ value: defaultImplementProfilePath, label: `自定义:${defaultImplementProfilePath}` }] : []), ]} /> 测试会话默认用此 profile;工单详情面板里的「测试配置文件」优先级更高。留空用 official。 )} > ({ value: p.path, label: p.name })), ...(defaultTestProfilePath && !profiles.some(p => p.path === defaultTestProfilePath) ? [{ value: defaultTestProfilePath, label: `自定义:${defaultTestProfilePath}` }] : []), ]} /> 原型会话默认用此 profile;工单详情面板里的「原型配置文件」优先级更高。留空用 official。 )} > ({ value: p.path, label: p.name })), ...(defaultPrototypeProfilePath && !profiles.some(p => p.path === defaultPrototypeProfilePath) ? [{ value: defaultPrototypeProfilePath, label: `自定义:${defaultPrototypeProfilePath}` }] : []), ]} /> ({ value: p.path, label: p.name })), ...(defaultAskProfilePath && !profiles.some(p => p.path === defaultAskProfilePath) ? [{ value: defaultAskProfilePath, label: `不可用:${defaultAskProfilePath}` }] : []), ]} /> ({ value: p.path, label: p.name })), ...(defaultRewriteProfilePath && !profiles.some(p => p.path === defaultRewriteProfilePath) ? [{ value: defaultRewriteProfilePath, label: `不可用:${defaultRewriteProfilePath}` }] : []), ]} /> 拖到「完成」时若 PR 与 main/dev 冲突,自动开的冲突解决会话使用此 profile;留空用默认 official。 )} > ({ value: p.path, label: p.name })), ...(conflictResolutionProfilePath && !profiles.some(p => p.path === conflictResolutionProfilePath) ? [{ value: conflictResolutionProfilePath, label: `自定义:${conflictResolutionProfilePath}` }] : []), ]} /> 顶部工具栏「提交代码」使用此 profile。留空则提交失败,不会回退默认。 )} > ({ value: p.path, label: p.name })), ...(commitProfilePath && !profiles.some(p => p.path === commitProfilePath) ? [{ value: commitProfilePath, label: `自定义:${commitProfilePath}` }] : []), ]} /> 「改动」tab 单文件说明和工单 PR 变更摘要使用此 profile。留空则生成失败,不会回退默认。 )} > ({ value: p.path, label: p.name })), ...(prDiffSummaryProfilePath && !profiles.some(p => p.path === prDiffSummaryProfilePath) ? [{ value: prDiffSummaryProfilePath, label: `自定义:${prDiffSummaryProfilePath}` }] : []), ]} /> 审查会话使用的 codex 模型( -c model= )。留空则用 codex {' '} config.toml {' '} 默认。 )} > setCodexModel(e.target.value)} placeholder="留空用 codex 默认(如 gpt-5.6-sol)" className={inputClass} /> 审查会话的 {' '} model_reasoning_effort ;留空则用 codex 默认。 )} > setCodexReasoningEffort(e.target.value)} placeholder="留空用 codex 默认(minimal/low/medium/high/xhigh)" className={inputClass} /> )} {activeGroup === 'network' && ( <> 扩展 HTTP server 绑定的端口。在 gitea 仓库 Webhooks 配置里把目标 URL 指向 {' '} {'http://<你的本机/公网域名>:<此端口>/webhook'} ,事件需勾选: 工单 (issue opened/edited)、 合并请求 (PR opened/synchronize/closed)、 工单评论 (codex 审查回流)。 )} > setWebhookPort(e.target.value)} className={inputClass} /> 与 gitea Webhooks 配置里的 {' '} 密钥文本 {' '} 保持一致,用于校验 {' '} X-Gitea-Signature 。webhook 走公网隧道时必填,否则任何人都能伪造事件;留空 = 不校验。 )} > setWebhookSecret(e.target.value)} placeholder="留空表示不校验签名" className={inputClass} /> claude --settings 用的 .json 所在目录。留空自动探测(优先 {' '} ~/Sources/cruldra-profile/claude-config/profiles )。支持 {' '} ~ 。 )} > setProfilesDirectory(e.target.value)} placeholder="~/Sources/cruldra-profile/claude-config/profiles" className={inputClass} /> )} {activeGroup === 'youtrack' && ( <> setYoutrackBaseUrl(e.target.value)} placeholder="https://xxx.youtrack.cloud" className={inputClass} /> 在 YouTrack「个人资料 → Account Security → New Token」生成。 {initialYoutrackTokenSaved && ( <>
已保存,留空则保留 )} )} > setYoutrackToken(e.target.value)} placeholder={initialYoutrackTokenSaved ? '••••••••' : 'perm-xxxx…'} className={inputClass} />
setYoutrackProjectShortName(e.target.value)} placeholder="LXF" className={inputClass} />
{youtrackProjects.status === 'ready' && youtrackProjects.projects.length > 0 && ( ({ value: p.shortName, label: `${p.shortName} · ${p.name}`, })), ]} /> )} {youtrackProjects.status === 'error' && ( 加载失败: {youtrackProjects.error} )}
setYoutrackCloseCommand(e.target.value)} placeholder="留空 = 自动探测" className={inputClass} /> )} {activeGroup === 'prompts' && ( <> Handlebars 模板。 {'{{#if issueNumber}}'} 分支 = 对已有工单续聊( {'{{issueNumber}}'} ,里面再用 {'{{#if prototypeFile}}'} 嵌原型段落、 {'{{#if prototypeSessionId}}'} 控制原型会话 id 那行); {'{{else}}'} 分支 = 新建工单( {'{{userRequest}}'} 、 {'{{nonce}}'} )。工单 state JSON 的字段( {'{{planFile}}'} 、 {'{{branch}}'} 等)都能直接用。 )} >