/** * 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 { useEffect, useState } from 'react' import { X } from 'lucide-react' import type { ClaudeProfile } from '../hooks/useIssues' type GroupKey = 'auth' | 'network' | 'youtrack' | 'prompts' | 'hooks' interface SubmitValues { host: string token: string webhookPort: number brainstormPrompt: string implementPlanPrompt: string autoReview: boolean reviewPrompt: string devBranch: string autoBuildBranch: string worktreeDirectory: string worktreeOpenWith: 'vscode' | 'pycharm' pycharmCommand: string worktreePostCreateScript: string worktreePreRemoveScript: string implTabPreCreateScript: string implTabPostCloseScript: string conflictResolutionProfilePath: string commitProfilePath: 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 initialBrainstormPrompt: string initialImplementPlanPrompt: string initialAutoReview: boolean initialReviewPrompt: string initialDevBranch: string initialAutoBuildBranch: string initialWorktreeDirectory: string initialWorktreeOpenWith: 'vscode' | 'pycharm' initialPycharmCommand: string initialWorktreePostCreateScript: string initialWorktreePreRemoveScript: string initialImplTabPreCreateScript: string initialImplTabPostCloseScript: string initialConflictResolutionProfilePath: string initialCommitProfilePath: 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)]' interface FieldProps { label: string hint?: React.ReactNode error?: string children: React.ReactNode } function Field({ label, hint, error, children }: FieldProps) { return ( {label} {children} {hint && ( {hint} )} {error && ( {error} )} ) } 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, initialBrainstormPrompt, initialImplementPlanPrompt, initialAutoReview, initialReviewPrompt, initialDevBranch, initialAutoBuildBranch, initialWorktreeDirectory, initialWorktreeOpenWith, initialPycharmCommand, initialWorktreePostCreateScript, initialWorktreePreRemoveScript, initialImplTabPreCreateScript, initialImplTabPostCloseScript, initialConflictResolutionProfilePath, initialCommitProfilePath, 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 [brainstormPrompt, setBrainstormPrompt] = useState(initialBrainstormPrompt) const [implementPlanPrompt, setImplementPlanPrompt] = useState(initialImplementPlanPrompt) const [autoReview, setAutoReview] = useState(initialAutoReview) const [reviewPrompt, setReviewPrompt] = useState(initialReviewPrompt) const [devBranch, setDevBranch] = useState(initialDevBranch) const [autoBuildBranch, setAutoBuildBranch] = useState(initialAutoBuildBranch) 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 [conflictResolutionProfilePath, setConflictResolutionProfilePath] = useState(initialConflictResolutionProfilePath) const [commitProfilePath, setCommitProfilePath] = useState(initialCommitProfilePath) 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)) setBrainstormPrompt(initialBrainstormPrompt) setImplementPlanPrompt(initialImplementPlanPrompt) setAutoReview(initialAutoReview) setReviewPrompt(initialReviewPrompt) setDevBranch(initialDevBranch) setAutoBuildBranch(initialAutoBuildBranch) setWorktreeDirectory(initialWorktreeDirectory) setWorktreeOpenWith(initialWorktreeOpenWith) setPycharmCommand(initialPycharmCommand) setWorktreePostCreateScript(initialWorktreePostCreateScript) setWorktreePreRemoveScript(initialWorktreePreRemoveScript) setImplTabPreCreateScript(initialImplTabPreCreateScript) setImplTabPostCloseScript(initialImplTabPostCloseScript) setConflictResolutionProfilePath(initialConflictResolutionProfilePath) setCommitProfilePath(initialCommitProfilePath) setCodexModel(initialCodexModel) setCodexReasoningEffort(initialCodexReasoningEffort) setProfilesDirectory(initialProfilesDirectory) setYoutrackBaseUrl(initialYoutrackBaseUrl) setYoutrackProjectShortName(initialYoutrackProjectShortName) setYoutrackCloseCommand(initialYoutrackCloseCommand) setYoutrackToken('') setErrors({}) // eslint-disable-next-line react-hooks/exhaustive-deps }, [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, brainstormPrompt, implementPlanPrompt, autoReview, reviewPrompt, devBranch: devBranch.trim(), autoBuildBranch: autoBuildBranch.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(), // 冲突解决 profile:空串 = 用 DEFAULT_PROFILE_PATH conflictResolutionProfilePath: conflictResolutionProfilePath.trim(), // 提交代码 profile:空串 = 未配置 → 提交严格失败 commitProfilePath: commitProfilePath.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 ( setActiveGroup(g.key)} className={[ 'px-3 py-1.5 text-left text-xs', active ? 'bg-white/10 text-[var(--vscode-foreground)]' : 'opacity-80 hover:bg-white/5', ].join(' ')} > {g.label} ) })} {/* 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} /> gitea webhook → Jenkins 监听的分支。留空表示与开发分支相同,同步按钮会一直禁用。 > )} > setAutoBuildBranch(e.target.value)} placeholder="留空 = 与开发分支相同" className={inputClass} /> 拖到「完成」时若 PR 与 main/dev 冲突,自动开的冲突解决会话使用此 profile;留空用默认 official。 > )} > setConflictResolutionProfilePath(e.target.value)} className={inputClass} > (默认) {profiles.map(p => ( {p.name} ))} {conflictResolutionProfilePath && !profiles.some(p => p.path === conflictResolutionProfilePath) && ( {`自定义:${conflictResolutionProfilePath}`} )} 顶部工具栏「提交代码」使用此 profile。留空则提交失败,不会回退默认。 > )} > setCommitProfilePath(e.target.value)} className={inputClass} > (未配置) {profiles.map(p => ( {p.name} ))} {commitProfilePath && !profiles.some(p => p.path === commitProfilePath) && ( {`自定义:${commitProfilePath}`} )} 审查会话使用的 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} /> setAutoReview(e.target.checked)} /> 启用 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} /> onListYouTrackProjects(youtrackBaseUrl.trim(), youtrackToken.trim())} className="shrink-0 rounded border border-[var(--vscode-button-border,transparent)] bg-[var(--vscode-button-secondaryBackground)] px-2 py-1 text-xs text-[var(--vscode-button-secondaryForeground)] hover:opacity-90" > {youtrackProjects.status === 'loading' ? '加载中…' : '加载项目'} {youtrackProjects.status === 'ready' && youtrackProjects.projects.length > 0 && ( setYoutrackProjectShortName(e.target.value)} className={`${inputClass} mt-2`} > — 选择项目 — {youtrackProjects.projects.map(p => ( {p.shortName} {' · '} {p.name} ))} )} {youtrackProjects.status === 'error' && ( 加载失败: {youtrackProjects.error} )} setYoutrackCloseCommand(e.target.value)} placeholder="留空 = 自动探测" className={inputClass} /> > )} {activeGroup === 'prompts' && ( <> 可用占位符: {'{userRequest}'} 、 {'{nonce}'} 。同时承担:建工单初始指令 + 会话内 spec/plan 路径写回 issue body 的持续约定。 > )} > setBrainstormPrompt(e.target.value)} className={`${inputClass} resize-y font-mono`} placeholder="头脑风暴 + 建工单 + 持续约定提示词" /> 可用占位符: {'{planFile}'} 、 {'{issueNumber}'} > )} > setImplementPlanPrompt(e.target.value)} className={`${inputClass} resize-y font-mono`} /> 可用占位符:{'{prNumber}'}>} > setReviewPrompt(e.target.value)} className={`${inputClass} resize-y font-mono`} /> > )} {activeGroup === 'hooks' && ( <> 实施 Plan 时创建 worktree 的目录模板。占位符 {' '} $project_root (工作区绝对路径)、 $project_name (工作区目录名)、 $feature_name (spec/plan 的 slug);开头 {' '} ~ {' '} 展开为主目录。留空 = 默认 {' '} ~/Sources/worktree/$project_name/$feature_name 。 > )} > setWorktreeDirectory(e.target.value)} placeholder="~/Sources/worktree/$project_name/$feature_name" className={inputClass} /> 点击工单详情里的工作树时用哪个编辑器打开。PyCharm 需 {' '} pycharm {' '} 命令在 PATH 中(JetBrains Toolbox 里启用命令行启动器)。 > )} > setWorktreeOpenWith(e.target.value as 'vscode' | 'pycharm')} className={inputClass} > VS Code PyCharm {worktreeOpenWith === 'pycharm' && ( 可填绝对路径或 {' '} ~ {' '} 开头路径(如 {' '} ~/.local/share/JetBrains/Toolbox/scripts/pycharm );GUI 启动的 VS Code 可能找不到裸 {' '} pycharm ,填全路径最稳。 > )} > setPycharmCommand(e.target.value)} placeholder="pycharm" className={inputClass} /> )} 工单进入"实施"后, git worktree add {' '} 成功立刻执行。留空 = 默认 {' '} .spx/worktree-post-create.sh ;文件不存在 = 静默跳过,不报错。 环境变量: WORKTREE_PATH 、 WORKSPACE_ROOT 、 BRANCH 、 ISSUE_NUMBER 、 MAIN_BRANCH 。超时 30s。 > )} > setWorktreePostCreateScript(e.target.value)} placeholder=".spx/worktree-post-create.sh" className={inputClass} /> 工单关闭 / 手动删 worktree 时, git worktree remove {' '} 之前执行,便于关闭 IDE / 归档日志。留空 = 默认 {' '} .spx/worktree-pre-remove.sh 。失败不阻塞删除。 > )} > setWorktreePreRemoveScript(e.target.value)} placeholder=".spx/worktree-pre-remove.sh" className={inputClass} /> 实施 cc tab 创建之前执行;常用于启动 IDE(pycharm 等)。 留空 = 默认 {' '} .spx/impl-tab-pre-create.sh 。tab 复用 fast path 不触发。 > )} > setImplTabPreCreateScript(e.target.value)} placeholder=".spx/impl-tab-pre-create.sh" className={inputClass} /> 实施 cc tab 被关闭后执行;常用于关闭 pre-create 启动的 IDE 进程。 留空 = 默认 {' '} .spx/impl-tab-post-close.sh 。 > )} > setImplTabPostCloseScript(e.target.value)} placeholder=".spx/impl-tab-post-close.sh" className={inputClass} /> > )} {/* Footer */} {canCancel && onCancel && ( 取消 )} 保存 ) }
main
-c model=
config.toml
model_reasoning_effort
{'http://<你的本机/公网域名>:<此端口>/webhook'}
~/Sources/cruldra-profile/claude-config/profiles
~
{'{userRequest}'}
{'{nonce}'}
{'{planFile}'}
{'{issueNumber}'}
{'{prNumber}'}
$project_root
$project_name
$feature_name
~/Sources/worktree/$project_name/$feature_name
pycharm
~/.local/share/JetBrains/Toolbox/scripts/pycharm
git worktree add
.spx/worktree-post-create.sh
WORKTREE_PATH
WORKSPACE_ROOT
BRANCH
ISSUE_NUMBER
MAIN_BRANCH
git worktree remove
.spx/worktree-pre-remove.sh
.spx/impl-tab-pre-create.sh
.spx/impl-tab-post-close.sh