## 岛上能直接作答 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>
61 lines
2.6 KiB
Swift
61 lines
2.6 KiB
Swift
import Foundation
|
|
|
|
/// 项目列表的排序规则。抽成纯函数:岛和主窗口共用一套口径,
|
|
/// 写在 View 里既没法测,也迟早会悄悄分叉。
|
|
public enum ProjectOrdering {
|
|
|
|
/// 统一口径:**置顶 > 正在开发(有会话在跑)> 最近有提交 > 名字**。
|
|
///
|
|
/// 为什么是这个顺序:
|
|
/// - 置顶是用户明确表达过的"我要它在最上面",压过一切瞬时状态;
|
|
/// - "现在有会话在跑"就是此刻正在做的事,找它的概率最高;
|
|
/// - 其余按最后提交时间倒序 —— 三四十个项目按 yaml 顺序排的话,
|
|
/// 找一个要翻半屏,而人真正会回去的基本都是最近动过的那几个;
|
|
/// - 从来没提交过 / 读不到 git 的排最后,同档按名字,保证顺序稳定
|
|
/// (列表每 6 秒刷新一次,排序不稳定会让行来回跳)。
|
|
public static func rank(
|
|
_ projects: [Project],
|
|
pinned: Set<String>,
|
|
runningCount: (Project) -> Int,
|
|
lastCommitAt: (Project) -> Date?
|
|
) -> [Project] {
|
|
projects.sorted { lhs, rhs in
|
|
let lPinned = pinned.contains(lhs.id)
|
|
let rPinned = pinned.contains(rhs.id)
|
|
if lPinned != rPinned { return lPinned }
|
|
|
|
let lRunning = runningCount(lhs) > 0
|
|
let rRunning = runningCount(rhs) > 0
|
|
if lRunning != rRunning { return lRunning }
|
|
|
|
switch (lastCommitAt(lhs), lastCommitAt(rhs)) {
|
|
case let (l?, r?) where l != r: return l > r
|
|
case (nil, _?): return false
|
|
case (_?, nil): return true
|
|
default: break
|
|
}
|
|
|
|
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
|
|
}
|
|
}
|
|
|
|
/// 岛上的口径。和主窗口同一套,只是岛上没有搜索框,直接排全量。
|
|
public static func islandOrder(
|
|
_ projects: [Project],
|
|
pinned: Set<String>,
|
|
runningCount: (Project) -> Int,
|
|
lastCommitAt: (Project) -> Date? = { _ in nil }
|
|
) -> [Project] {
|
|
rank(projects, pinned: pinned, runningCount: runningCount, lastCommitAt: lastCommitAt)
|
|
}
|
|
|
|
/// 只把置顶项提前,两组内部都保持传入顺序。
|
|
/// 需要"完全不打乱原始顺序"的场景用它(当前没有调用方,保留给按 yaml
|
|
/// 手工分组浏览的场景)。
|
|
public static func pinnedFirst(_ projects: [Project], pinned: Set<String>) -> [Project] {
|
|
guard !pinned.isEmpty else { return projects }
|
|
return projects.filter { pinned.contains($0.id) }
|
|
+ projects.filter { !pinned.contains($0.id) }
|
|
}
|
|
}
|