## 岛上能直接作答 Claude 的交互请求
AskUserQuestion / ExitPlanMode 的 tool_input 里没有 command/path,
RiskClassifier 提不出 summary,风险判定为 normal 直接放行 ——
岛上从来不弹任何东西。这是这次要修的核心 bug。
现在按 toolName 在风险判定**之前**分流,岛上弹可作答的卡:
- 选择题:单题单选点选即答;多题表单逐题作答,多选勾完提交,
最后合成一条回传。答案借 deny 的 reason 送回模型(PreToolUse
没有「替用户作答」的通道,这是唯一的口子)。
- 计划审批:显式 allow 压不掉 plan 状态机自己的确认框(实测
2.1.220),所以「批准」= 放行后替用户在终端框里按 1/2,带重试
等对话框渲染出来;驳回走 hook deny 有效。
- 进入计划模式(EnterPlanMode)同样接管。
- 终端权限框("Do you want to...")走 Notification hook 弹卡,
允许=注入 1、拒绝=Esc,复用 TerminalReply 的全套护栏。
超时方向和审批卡**相反**:审批超时必须 deny(唯一的安全刹车),
交互卡超时 allow(不输出决策 = 交还终端原生对话框,用户零损失)。
这条镜像关系有专门的测试钉住。
## 项目列表
- 置顶:岛和主窗口菜单里都能置顶,落盘 pinned.json。
- 排序统一成 置顶 > 正在开发 > 最近提交 > 名字。GitInfo 加
lastCommitAt(38 个仓库合计约 300ms,后台队列)—— 三十多个项目
按 yaml 顺序排,找一个要翻半屏。
- 岛上无会话的行点击弹启动菜单,不再直接起 claude(误触成本太高)。
用原生 NSMenu:SwiftUI Menu 的两种整行方案都翻过车(label 被
压扁 / 透明盖层收不到点击)。
## 建仓与克隆
- scripts/new-project.sh:目录 + git + GitHub 远程 + 防污染
settings.json + 注册 projects.yaml,一条命令。app、/new-project
skill、命令行三个入口共用同一份逻辑。
- scripts/clone-project.sh + 克隆表单:列出账号可见的全部仓库
(含组织仓,之前 gh repo list 只列个人仓),按 owner 分组筛选,
多账号可切换(gh 只用活跃账号的凭据,不切就拉不了另一个账号的
私有仓)。列表按账号分开缓存 + 后台静默刷新。
- 防污染 deny 刻意**不禁** ~/.claude/plans/:plan mode 的计划文件
写在那里,禁了所有 plan 会话都会报 Error writing file(真踩过)。
## 顺带修掉
add-project.sh 最后一行是 `[ ${#ALIASES[@]} -gt 0 ] && echo ...`,
没有别名时测试为假,整个脚本退出码变成 1 —— 明明写进去了,调用方
却报「注册 projects.yaml 失败」。改成显式 exit 0。
Co-authored-by: wuguangan <anjaihd0206@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
182 lines
6.4 KiB
Swift
182 lines
6.4 KiB
Swift
import AppKit
|
||
import HubProbe
|
||
import HubProjects
|
||
import SwiftUI
|
||
|
||
/// 主窗口的「新建项目」表单。
|
||
///
|
||
/// 真正干活的是 `scripts/new-project.sh`(命令行、/new-project skill、这里
|
||
/// 三个入口共用同一份逻辑),app 只收集参数、跑脚本、展示输出 ——
|
||
/// 建仓流程只维护一份,改脚本三处同时生效。
|
||
struct NewProjectSheet: View {
|
||
let projects: ProjectStore
|
||
@Environment(\.dismiss) private var dismiss
|
||
|
||
@State private var command = NewProjectCommand()
|
||
@State private var phase: Phase = .editing
|
||
@State private var output = ""
|
||
|
||
private enum Phase: Equatable {
|
||
case editing
|
||
case running
|
||
case finished(success: Bool)
|
||
}
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: 14) {
|
||
Text("新建项目")
|
||
.font(.system(size: 16, weight: .semibold, design: .rounded))
|
||
|
||
form
|
||
|
||
if let error = command.validationError, phase == .editing,
|
||
!command.name.isEmpty {
|
||
Text(error)
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.red)
|
||
} else if phase == .editing {
|
||
Text("将创建:\(command.targetPath)")
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.foregroundStyle(.secondary)
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
}
|
||
|
||
if phase != .editing {
|
||
outputView
|
||
}
|
||
|
||
Spacer(minLength: 0)
|
||
buttons
|
||
}
|
||
.padding(20)
|
||
.frame(width: 520, height: 420)
|
||
}
|
||
|
||
private var form: some View {
|
||
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) {
|
||
GridRow {
|
||
Text("项目名")
|
||
TextField("如 feed-sync", text: $command.name)
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
GridRow {
|
||
Text("父目录")
|
||
HStack(spacing: 6) {
|
||
TextField("~/Documents/code", text: $command.parentDir)
|
||
.textFieldStyle(.roundedBorder)
|
||
Button("选…") { pickParentDir() }
|
||
}
|
||
}
|
||
GridRow {
|
||
Text("描述")
|
||
TextField("可选,进 README 和项目列表", text: $command.descriptionText)
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
GridRow {
|
||
Text("远程")
|
||
Picker("", selection: $command.remote) {
|
||
ForEach(NewProjectCommand.Remote.allCases) { remote in
|
||
Text(remote.label).tag(remote)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.labelsHidden()
|
||
}
|
||
GridRow {
|
||
Text("")
|
||
Toggle(isOn: $command.applyDenyGuard) {
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("防污染 deny(推荐)")
|
||
.font(.system(size: 12))
|
||
Text("禁止项目会话写宿主机 ~/.claude/ 全局面;记忆落项目内。plans 刻意放行。")
|
||
.font(.system(size: 10))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.disabled(phase == .running)
|
||
}
|
||
|
||
private var outputView: some View {
|
||
ScrollView {
|
||
Text(output.isEmpty ? "执行中…" : output)
|
||
.font(.system(size: 11, design: .monospaced))
|
||
.textSelection(.enabled)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
}
|
||
.padding(8)
|
||
.frame(maxHeight: 140)
|
||
.background(Color.black.opacity(0.05), in: .rect(cornerRadius: 6))
|
||
}
|
||
|
||
private var buttons: some View {
|
||
HStack {
|
||
if case .finished(let success) = phase, success {
|
||
Label("已创建并加入项目列表", systemImage: "checkmark.circle.fill")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(.green)
|
||
}
|
||
Spacer()
|
||
if case .finished = phase {
|
||
Button("完成") { dismiss() }
|
||
.keyboardShortcut(.defaultAction)
|
||
} else {
|
||
Button("取消") { dismiss() }
|
||
.disabled(phase == .running)
|
||
Button(phase == .running ? "创建中…" : "创建") { create() }
|
||
.keyboardShortcut(.defaultAction)
|
||
.disabled(command.validationError != nil || phase == .running)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func pickParentDir() {
|
||
let panel = NSOpenPanel()
|
||
panel.canChooseDirectories = true
|
||
panel.canChooseFiles = false
|
||
panel.directoryURL = URL(
|
||
fileURLWithPath: NSString(string: command.parentDir).expandingTildeInPath
|
||
)
|
||
if panel.runModal() == .OK, let url = panel.url {
|
||
command.parentDir = url.path
|
||
}
|
||
}
|
||
|
||
private func create() {
|
||
guard command.validationError == nil else { return }
|
||
phase = .running
|
||
output = ""
|
||
let cmd = command
|
||
let script = NewProjectCommand.scriptURL(near: projects.sourceURL).path
|
||
Task.detached(priority: .userInitiated) {
|
||
// PATH 必须补全:GUI app 的环境里没有 /usr/local/bin,gh 会找不到。
|
||
let result = Shell.run(
|
||
"/bin/bash",
|
||
cmd.arguments(scriptPath: script),
|
||
timeout: 180,
|
||
environment: ["PATH": NewProjectCommand.guiPATH]
|
||
)
|
||
let text = Self.stripANSI(
|
||
[result.stdout, result.stderr]
|
||
.filter { !$0.isEmpty }
|
||
.joined(separator: "\n")
|
||
)
|
||
await MainActor.run {
|
||
output = text
|
||
phase = .finished(success: result.succeeded)
|
||
// 脚本已把新项目写进 projects.yaml,重载后岛和主窗口同时看到。
|
||
if result.succeeded { projects.load() }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 脚本输出带终端色码,GUI 里显示要剥掉。
|
||
nonisolated static func stripANSI(_ text: String) -> String {
|
||
text.replacingOccurrences(
|
||
of: "\u{1B}\\[[0-9;]*m", with: "", options: .regularExpression
|
||
)
|
||
}
|
||
}
|