Files
vibe-foreman/HubKit/Sources/HubUI/MainWindow.swift
T
wuguanganandClaude Opus 5 f0f6cd7454 feat: 验收守望 —— 独立于 Claude 自述的需求清单
Claude 会把用户需求翻译成自己的 TodoWrite,翻译那一步就已经丢东西了:
「修复 apple-header sticky」是施工步骤,不是原始需求里的功能要点。
再拿它自己的 todo 去验收,等于让它自己判自己的卷子。

清单挂在项目上跨会话累积,基线取自用户原话和用户批准过的计划。

几个刻意的设计:

- Evidence 在类型层面就分开 claimed / diff / ran。全塞进 [String] 等于在
  数据结构上承认「它说改了」和「它真改了」是一回事,而这正是要解决的问题。
  汇报条的「已证」只数 isProof 的,想把自述渲染成已验证得先改类型。

- 拦截的「上膛」只有 UserPromptSubmit 能做,Stop 拦过立即卸膛。Claude
  没有任何路径能给自己上膛,所以「拦一次 → 它回一轮 → 又拦」这个死循环
  是构造不出来的,不是被 if 挡住的。

- applyAudit 跳过用户终裁过的项。勾了「接受」一转身被机器打回 disputed,
  那个勾就白点了。

- extract 区分「失败」(nil) 和「没有新要点」([])。混在一起的话模型抽风
  一次就会清掉原话缓冲,而那是这个功能唯一的基线来源。

hook 从 5 类补到 9 类。PostToolUse 是最大的收获:Write/Edit 之后 Hub
第一手知道哪个文件被改了,不用回头问 Claude——问这个问题本身就给了
转述失真的机会。

实测确认(都不是推断):
- hubctl hook userprompt 的 stdout 是 0 字节。这条必须验,因为
  UserPromptSubmit 的 stdout 会被当成附加 context 注入进对话。
- 项目级和全局 hook:命令字符串相同时 Claude Code 自己去重,不同才叠加。
  第一次实验用了两条不同命令,据此普遍化成「一定会重复」是错的。
  HookDedup 保留为深度防御(路径不一致时才真用得上),不是必需品。
- 真实拆解链路 20 秒出结果,正确忽略了缓冲里的闲聊、正确去重了已有条目。

ensure-project-config.sh 接了三个调用点。其中 TerminalDispatch.open() 是
关键:app 从岛/主窗口启动项目完全不经过 project-menu.sh,漏了那里等于
最常用的入口一个都没检查。

326 个测试。上膛、自述不算证明、外部编辑不被覆盖三条做过变异验证。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 17:50:25 +08:00

911 lines
39 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import HubCore
import HubIPC
import HubProjects
import SwiftUI
/// 主窗口的分区。
public enum DashboardSection: String, CaseIterable, Identifiable, Sendable {
case sessions = "会话"
case acceptance = "验收"
case projects = "项目"
case usage = "用量"
case approvals = "审批日志"
case settings = "设置"
public var id: String { rawValue }
var symbol: String {
switch self {
case .sessions: return "bolt.horizontal.circle"
case .acceptance: return "checklist"
case .projects: return "folder"
case .usage: return "chart.bar"
case .approvals: return "checkmark.shield"
case .settings: return "gearshape"
}
}
}
/// 主窗口。
///
/// 分工:**岛 = 一瞥**(零点击获取状态);**主窗口 = 深挖**(用量趋势、
/// 项目管理、审批日志)。两者共享同一个 `SessionStore` 和同一套原子组件
/// (`PixelSprite` / `StatusDot` / `GitChip`),这是设计一致性的技术保障 ——
/// 维护两套组件的话它们必然会漂移。
///
/// 视觉上和岛刻意相反:岛强制暗色(因为要贴合恒为黑的刘海),
/// 主窗口完全跟随系统。岛是"设备的一部分",窗口是"一个 app"。
public struct MainWindowView: View {
@Bindable var store: SessionStore
@Bindable var projects: ProjectStore
@Bindable var approvals: ApprovalCoordinator
@Bindable var placement: IslandPlacementStore
@Bindable var acceptance: AcceptanceStore
let channels: HookChannelMonitor
let onJump: (String) -> Void
let onLaunch: (Project, LaunchMode) -> Void
@State private var section: DashboardSection = .sessions
@State private var search = ""
/// 当前在「验收」页看的是哪个项目。会话行的「清单」按钮会写它。
@State private var ledgerPath: String?
/// Git 账号/仓库列表。挂在窗口这一层:克隆表单每次打开复用同一份数据
/// (带磁盘缓存),只做后台静默刷新,不再让用户每次干等加载。
@State private var git = GitAccountStore()
public init(
store: SessionStore,
projects: ProjectStore,
approvals: ApprovalCoordinator,
placement: IslandPlacementStore,
acceptance: AcceptanceStore,
channels: HookChannelMonitor,
onJump: @escaping (String) -> Void,
onLaunch: @escaping (Project, LaunchMode) -> Void
) {
self.store = store
self.projects = projects
self.approvals = approvals
self.placement = placement
self.acceptance = acceptance
self.channels = channels
self.onJump = onJump
self.onLaunch = onLaunch
}
public var body: some View {
NavigationSplitView {
List(DashboardSection.allCases, selection: $section) { item in
Label(item.rawValue, systemImage: item.symbol)
.tag(item)
}
.navigationSplitViewColumnWidth(min: 190, ideal: 210, max: 260)
} detail: {
detail
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
// 单一强调色,和岛里 busy 的蓝一致,形成跨形态的一致性。
.tint(IslandTheme.busy)
}
@ViewBuilder
private var detail: some View {
switch section {
case .sessions:
SessionsPane(
store: store, projects: projects, onJump: onJump,
onOpenLedger: { session in
ledgerPath = AcceptanceStore.projectPath(
forCWD: session.cwd, projects: projects
)
section = .acceptance
}
)
case .acceptance:
AcceptancePane(
acceptance: acceptance, projects: projects, store: store, selection: $ledgerPath
)
case .projects:
ProjectsPane(projects: projects, store: store, git: git, onLaunch: onLaunch)
case .usage:
UsagePane()
case .approvals:
ApprovalLogPane(approvals: approvals)
case .settings:
SettingsPane(
approvals: approvals, projects: projects, git: git,
placement: placement, channels: channels
)
}
}
}
// MARK: - 通用零件
/// 页面大标题。28pt rounded 是 iOS 26 的大标题语言。
struct PaneHeader: View {
let title: String
let subtitle: String?
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: 28, weight: .bold, design: .rounded))
if let subtitle {
Text(subtitle)
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 12)
}
}
/// git 状态芯片。
struct GitChip: View {
let info: GitInfo?
var body: some View {
guard let info else { return AnyView(EmptyView()) }
return AnyView(
HStack(spacing: 6) {
if let branch = info.branch {
Label(branch, systemImage: "arrow.triangle.branch")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(1)
}
if info.changeCount > 0 {
Text("+\(info.changeCount)")
.font(.system(size: 11, weight: .medium)).monospacedDigit()
.foregroundStyle(IslandTheme.waiting)
}
if info.ahead > 0 {
Text("↑\(info.ahead)")
.font(.system(size: 11)).monospacedDigit()
.foregroundStyle(IslandTheme.busy)
}
if info.behind > 0 {
Text("↓\(info.behind)")
.font(.system(size: 11)).monospacedDigit()
.foregroundStyle(IslandTheme.busy)
}
if !info.hasUpstream {
Text("无远端")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
}
}
)
}
}
/// 内容区卡片。**实心,不用玻璃** —— 大面积玻璃在长时间阅读区会疲劳,
/// 而且每张卡片都要采样一次背景,9 张就是 9 次。玻璃留给 sidebar 和 toolbar
/// (那是系统自己给的)。
struct Card<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
content
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
.background.secondary,
in: .rect(cornerRadius: 14, style: .continuous)
)
}
}
// MARK: - 会话
struct SessionsPane: View {
let store: SessionStore
let projects: ProjectStore
let onJump: (String) -> Void
let onOpenLedger: (AgentSession) -> Void
var body: some View {
VStack(spacing: 0) {
PaneHeader(
title: "会话",
subtitle: "\(store.sessions.count) 个运行中 · \(store.waitingCount) 个等待你"
)
ScrollView {
LazyVStack(spacing: 8) {
ForEach(store.sessions) { session in
Card {
HStack(spacing: 12) {
SpriteScreen(cornerRadius: 8) {
PixelSprite(session: session, pixelSize: 2, gridSize: 16)
}
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
StatusDot(status: session.status, size: 7)
Text(session.name ?? session.sessionId)
.font(.system(size: 14, weight: .semibold,
design: .rounded))
.lineLimit(1)
.truncationMode(.middle)
if session.kind == .bg {
Text("后台")
.font(.system(size: 10))
.foregroundStyle(.secondary)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(.quaternary, in: .capsule)
}
}
Text(projectLabel(for: session))
.font(.system(size: 12))
.foregroundStyle(.secondary)
if let waitingFor = session.waitingFor {
Text(waitingFor)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(IslandTheme.waiting)
}
}
Spacer(minLength: 8)
GitChip(info: gitInfo(for: session))
Button("清单") { onOpenLedger(session) }
.buttonStyle(.borderless)
.font(.system(size: 12, weight: .medium))
Button("跳转") { onJump(session.sessionId) }
.buttonStyle(.borderless)
.font(.system(size: 12, weight: .medium))
}
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
}
}
private func projectLabel(for session: AgentSession) -> String {
projects.projectName(forPath: session.cwd) ?? session.fallbackProjectName
}
private func gitInfo(for session: AgentSession) -> GitInfo? {
guard let name = projects.projectName(forPath: session.cwd),
let project = projects.projects.first(where: { $0.name == name })
else { return nil }
return projects.git(for: project)
}
}
// MARK: - 项目
struct ProjectsPane: View {
let projects: ProjectStore
let store: SessionStore
let git: GitAccountStore
let onLaunch: (Project, LaunchMode) -> Void
@State private var search = ""
@State private var showNewProject = false
@State private var showClone = false
private var filtered: [Project] {
let base: [Project]
if search.isEmpty {
base = projects.projects
} else {
let needle = search.lowercased()
base = projects.projects.filter {
$0.name.lowercased().contains(needle)
|| $0.path.lowercased().contains(needle)
|| $0.aliases.contains { $0.lowercased().contains(needle) }
|| ($0.description?.lowercased().contains(needle) ?? false)
}
}
// 置顶 > 正在开发 > 最近提交 > 名字。38 个项目按 yaml 顺序排的话,
// 找一个要翻半屏 —— 而人真正会回去的就是在跑的和最近动过的那几个。
return ProjectOrdering.rank(
base,
pinned: projects.pinned,
runningCount: { runningCount($0) },
lastCommitAt: { projects.git(for: $0)?.lastCommitAt }
)
}
/// 这个项目下有没有正在跑的会话。
private func runningCount(_ project: Project) -> Int {
let root = project.expandedPath
return store.sessions.count { $0.cwd == root || $0.cwd.hasPrefix(root + "/") }
}
var body: some View {
VStack(spacing: 0) {
PaneHeader(title: "项目", subtitle: "\(projects.projects.count) 个项目")
HStack(spacing: 10) {
TextField("搜索项目…", text: $search)
.textFieldStyle(.roundedBorder)
Button {
showNewProject = true
} label: {
Label("新建", systemImage: "plus")
}
Button {
showClone = true
} label: {
Label("克隆", systemImage: "arrow.down.circle")
}
Button {
projects.scan()
} label: {
Label("扫描", systemImage: "arrow.clockwise")
}
.disabled(projects.isScanning)
}
.padding(.horizontal, 20)
.padding(.bottom, 12)
.sheet(isPresented: $showNewProject) {
NewProjectSheet(projects: projects)
}
.sheet(isPresented: $showClone) {
CloneProjectSheet(projects: projects, git: git)
}
if projects.projects.isEmpty {
emptyState
Spacer()
}
ScrollView {
LazyVStack(spacing: 8) {
ForEach(filtered) { project in
Card {
HStack(spacing: 12) {
Circle()
.fill(runningCount(project) > 0
? IslandTheme.busy : Color.secondary.opacity(0.3))
.frame(width: 8, height: 8)
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 8) {
Text(project.name)
.font(.system(size: 14, weight: .semibold,
design: .rounded))
if projects.isPinned(project) {
Image(systemName: "pin.fill")
.font(.system(size: 9))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(45))
}
if runningCount(project) > 0 {
Text("\(runningCount(project)) 个会话")
.font(.system(size: 10))
.foregroundStyle(IslandTheme.busy)
}
}
if let description = project.description {
Text(description)
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
Text(project.path)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.tertiary)
.lineLimit(1)
.truncationMode(.head)
}
Spacer(minLength: 8)
GitChip(info: projects.git(for: project))
Menu {
ForEach(LaunchMode.allCases, id: \.self) { mode in
if mode != .resumeSession {
Button(mode.label) { onLaunch(project, mode) }
}
}
Divider()
Button(projects.isPinned(project) ? "取消置顶" : "置顶") {
projects.togglePin(project)
}
} label: {
Image(systemName: "play.circle")
}
.menuStyle(.borderlessButton)
.fixedSize()
}
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
}
}
/// 一个项目都没有时的空状态。
///
/// **必须说清楚在读哪个文件。** 这次的事故是 app 读的 projects.yaml 和
/// 脚本维护的那份不是同一个,而界面上只有一个孤零零的"0 个项目",
/// 完全没有线索。空状态要给出路径、原因和下一步动作。
private var emptyState: some View {
Card {
VStack(alignment: .leading, spacing: 10) {
Label("没有读到项目", systemImage: "exclamationmark.triangle")
.font(.system(size: 14, weight: .semibold, design: .rounded))
.foregroundStyle(IslandTheme.waiting)
if let diagnostic = projects.loadDiagnostic {
Text(diagnostic)
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 3) {
Text("数据文件")
.font(.system(size: 11))
.foregroundStyle(.tertiary)
Text(projects.sourceURL?.path ?? "(找不到 projects.yaml)")
.font(.system(size: 11, design: .monospaced))
.textSelection(.enabled)
}
HStack(spacing: 8) {
if let url = projects.sourceURL {
Button("在 Finder 中显示") {
NSWorkspace.shared.activateFileViewerSelecting([url])
}
}
Button("重新加载") { projects.load() }
Button("扫描 ~/Documents/code") { projects.scan() }
.disabled(projects.isScanning)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(.horizontal, 20)
}
}
// MARK: - 用量
struct UsagePane: View {
/// 默认 7 天而不是全部。
///
/// `~/.claude/projects` 实测有近 1 GB / 341 个 jsonl,全量首扫要好几十秒。
/// 打开页面就卡住不出数,用户读到的是"这个功能没做"。7 天视图靠 mtime
/// 过滤掉绝大多数文件,首屏两秒内出结果;想看全量再自己切。
@State private var range: UsageRange = .week7
@State private var summary: UsageSummary = .empty
@State private var loading = false
@State private var scanned = 0
@State private var total = 0
var body: some View {
VStack(spacing: 0) {
PaneHeader(
title: "用量",
subtitle: "\(summary.totalSessions) 个会话 · \(summary.totalMessages) 条消息"
)
Picker("", selection: $range) {
ForEach(UsageRange.allCases, id: \.self) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, 20)
.padding(.bottom, 12)
// 全量首扫要读近 1 GB。没有进度的话用户没法区分"在算"和"坏了"。
if loading {
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text(total > 0 ? "正在统计 \(scanned)/\(total) 个会话文件…" : "正在统计…")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
.padding(.bottom, 10)
}
HStack(spacing: 10) {
statTile("输入", summary.totalInput, IslandTheme.busy)
statTile("输出", summary.totalOutput, IslandTheme.shell)
}
.padding(.horizontal, 20)
.padding(.bottom, 12)
ScrollView {
LazyVStack(spacing: 6) {
ForEach(summary.projects) { usage in
Card {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(usage.projectName)
.font(.system(size: 13, weight: .semibold,
design: .rounded))
.lineLimit(1)
Spacer()
Text(format(usage.totalTokens))
.font(.system(size: 12)).monospacedDigit()
.foregroundStyle(.secondary)
}
GeometryReader { proxy in
let maxTokens = summary.projects.first?.totalTokens ?? 1
let ratio = max(
0.01, Double(usage.totalTokens) / Double(max(maxTokens, 1))
)
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(
LinearGradient(
colors: [
IslandTheme.busy.opacity(0.85),
IslandTheme.busy.opacity(0.35),
],
startPoint: .leading, endPoint: .trailing
)
)
.frame(width: proxy.size.width * ratio)
}
.frame(height: 6)
}
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
}
.task(id: range) { await reload() }
}
private func statTile(_ label: String, _ value: Int, _ color: Color) -> some View {
Card {
VStack(alignment: .leading, spacing: 2) {
Text(label).font(.system(size: 11)).foregroundStyle(.secondary)
Text(format(value))
.font(.system(size: 22, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(color)
}
}
}
private func format(_ value: Int) -> String {
if value >= 1_000_000 { return String(format: "%.1fM", Double(value) / 1_000_000) }
if value >= 1_000 { return String(format: "%.1fk", Double(value) / 1_000) }
return "\(value)"
}
private func reload() async {
loading = true
scanned = 0
total = 0
let range = self.range
// 解析要扫近 1 GB 的 jsonl,必须放后台;进度回调也从后台线程来,
// 所以要显式跳回 MainActor 再更新 @State。
let result = await Task.detached(priority: .userInitiated) {
UsageStats.collect(range: range) { done, count in
Task { @MainActor in
scanned = done
total = count
}
}
}.value
summary = result
loading = false
}
}
// MARK: - 审批日志
struct ApprovalLogPane: View {
let approvals: ApprovalCoordinator
var body: some View {
VStack(spacing: 0) {
PaneHeader(
title: "审批日志",
subtitle: "记录所有被识别为高风险的操作。看一周就知道该不该开启拦截。"
)
if approvals.log.isEmpty {
ContentUnavailableView(
"还没有记录",
systemImage: "checkmark.shield",
description: Text("高风险操作发生时会记在这里")
)
} else {
ScrollView {
LazyVStack(spacing: 6) {
ForEach(approvals.log) { entry in
Card {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 8) {
Text(entry.risk == .irreversible ? "不可逆" : "危险")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.black)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(
entry.risk == .irreversible
? IslandTheme.danger : IslandTheme.waiting,
in: .capsule
)
Text(entry.projectName)
.font(.system(size: 13, weight: .semibold))
Text(entry.toolName)
.font(.system(size: 11))
.foregroundStyle(.secondary)
Spacer()
Text(verdictLabel(entry))
.font(.system(size: 11, weight: .medium))
.foregroundStyle(verdictColor(entry))
Text(entry.timestamp, style: .time)
.font(.system(size: 11)).monospacedDigit()
.foregroundStyle(.tertiary)
}
Text(entry.command)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(2)
.textSelection(.enabled)
}
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
}
}
}
private func verdictLabel(_ entry: ApprovalLogEntry) -> String {
switch entry.verdict {
case "allow": return "已允许"
case "deny": return "已拒绝"
case "timeout": return "超时拒绝"
default: return entry.intercepted ? entry.verdict : "未拦截"
}
}
private func verdictColor(_ entry: ApprovalLogEntry) -> Color {
switch entry.verdict {
case "deny", "timeout": return IslandTheme.danger
case "allow": return IslandTheme.shell
default: return .secondary
}
}
}
// MARK: - 设置
struct SettingsPane: View {
@Bindable var approvals: ApprovalCoordinator
let projects: ProjectStore
let git: GitAccountStore
@Bindable var placement: IslandPlacementStore
let channels: HookChannelMonitor
@State private var hookStatus: String = "检查中…"
/// hook 通道健康度。
///
/// `hubctl doctor` 只能回答"socket 通不通"。但链路断掉的方式远不止这一种:
/// settings.json 被别的工具覆盖、event 名字拼错、Claude Code 改了名 ——
/// 这几种情况下 socket 是通的、doctor 是绿的,而那一类事件一条都收不到,
/// 验收清单会安静地不工作。
///
/// **只有真收到过事件才能证明通了。** 所以这里显示的是实际到达数,不是配置项。
@ViewBuilder
private var hookChannels: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Hook 通道")
.font(.system(size: 14, weight: .semibold))
Text("显示的是**实际收到过的事件数**,不是配置里写了什么 —— 配置对而链路断是最常见的故障。")
.font(.system(size: 11))
.foregroundStyle(.secondary)
ForEach(HookChannelMonitor.expected, id: \.self) { kind in
let count = channels.counts[kind] ?? 0
HStack(spacing: 8) {
Circle()
.fill(count > 0 ? IslandTheme.shell : Color.secondary.opacity(0.35))
.frame(width: 6, height: 6)
Text(HookChannelMonitor.label(for: kind))
.font(.system(size: 12))
Spacer()
if let last = channels.lastSeen[kind] {
Text(last, style: .relative)
.font(.system(size: 11)).monospacedDigit()
.foregroundStyle(.tertiary)
}
Text(count > 0 ? "\(count)" : "未收到")
.font(.system(size: 11, weight: count > 0 ? .medium : .regular))
.monospacedDigit()
.foregroundStyle(count > 0 ? .secondary : .tertiary)
}
}
Text("计数从 Claude Hub 启动时开始。刚装完 hook 的话,新开一个会话才会亮。")
.font(.system(size: 11))
.foregroundStyle(.tertiary)
.padding(.top, 2)
}
}
var body: some View {
VStack(spacing: 0) {
PaneHeader(title: "设置", subtitle: nil)
ScrollView {
VStack(spacing: 12) {
Card { hookChannels }
Card {
VStack(alignment: .leading, spacing: 10) {
Text("灵动岛位置").font(.system(size: 14, weight: .semibold))
Toggle("跟随光标去外接屏", isOn: $placement.followsCursor)
.toggleStyle(.checkbox)
Text("""
默认固定在有刘海的那块屏。开了之后岛会跟着光标跑到外接屏 —— \
但外接屏没有刘海,岛会变成悬浮在屏幕顶部的胶囊,正好压在\
浏览器标签栏那一带,底下的东西会点不动。想在外接屏上也看到\
状态再开。
""")
.font(.system(size: 11))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
Card {
VStack(alignment: .leading, spacing: 10) {
Text("审批拦截").font(.system(size: 14, weight: .semibold))
Picker("拦截门槛", selection: $approvals.interceptThreshold) {
Text("只拦不可逆操作(推荐)").tag(RiskLevel.irreversible)
Text("危险操作也拦").tag(RiskLevel.dangerous)
}
.pickerStyle(.radioGroup)
Text("""
你全局用的是 auto 权限模式 + --dangerously-skip-permissions,\
图的就是不被打断。把「危险操作」一档也拦上,按日常用量每天会弹 3–8 次。\
建议先看一周审批日志再决定。
""")
.font(.system(size: 11))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Git 账号").font(.system(size: 14, weight: .semibold))
Spacer()
Button {
git.refresh()
} label: {
Image(systemName: "arrow.clockwise")
}
.disabled(git.isRefreshing)
}
if git.accounts.isEmpty {
Text(git.diagnostic ?? (git.isRefreshing ? "检查中…" : "未检查"))
.font(.system(size: 12))
.foregroundStyle(.secondary)
} else {
ForEach(git.accounts) { account in
HStack(spacing: 6) {
Image(systemName: account.active
? "person.crop.circle.badge.checkmark"
: "person.crop.circle")
.foregroundStyle(account.active ? .green : .secondary)
Text("\(account.login) · \(account.host)")
.font(.system(size: 12, design: .monospaced))
if account.active {
Text("当前")
.font(.system(size: 10))
.foregroundStyle(.green)
if !git.repos.isEmpty {
Text("\(git.repos.count) 个仓库")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
}
} else {
Button("切换到这个") { git.switchTo(account) }
.font(.system(size: 11))
.disabled(git.isSwitching || git.isRefreshing)
}
}
}
}
Text("克隆用的是标「当前」的那个账号的凭据 —— 拉另一个账号的私有仓要先切过去。新增账号在终端跑:")
.font(.system(size: 11))
.foregroundStyle(.secondary)
HStack(spacing: 8) {
Text("gh auth login")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.tertiary)
Button("复制命令") {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString("gh auth login", forType: .string)
}
.font(.system(size: 11))
}
}
}
.onAppear { git.refresh() }
Card {
VStack(alignment: .leading, spacing: 8) {
Text("Hook 连接").font(.system(size: 14, weight: .semibold))
Text(hookStatus)
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(.secondary)
Text("socket:\(HubIPCPath.display)")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.tertiary)
.textSelection(.enabled)
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
Text("项目配置").font(.system(size: 14, weight: .semibold))
Text("\(projects.projects.count) 个项目")
.font(.system(size: 12)).foregroundStyle(.secondary)
// 把实际读的文件路径摆出来。踩过的坑:脚本维护的是
// 仓库里那份 projects.yaml,app 读的却是
// Application Support 里另一份旧的,界面上完全没有线索。
Text(projects.sourceURL?.path ?? "(找不到 projects.yaml)")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.tertiary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.head)
if let diagnostic = projects.loadDiagnostic {
Text(diagnostic)
.font(.system(size: 11))
.foregroundStyle(IslandTheme.waiting)
}
Button("重新加载 projects.yaml") { projects.load() }
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
}
.onAppear {
hookStatus = "✓ socket 正在监听,hook 事件会实时送达"
}
}
}
/// 给设置页显示 socket 路径用。放这里避免 HubUI 直接暴露 HubIPC 的类型给外部。
enum HubIPCPath {
static var display: String {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/claude-hub/hub.sock")
.path
}
}