Danger warning on approval cards (red card, reasons, no Always); file shelf in the top-left slot with 'На полку' after a drop
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -52,7 +52,7 @@ struct AppGridView: View {
|
||||
ZStack {
|
||||
VStack(spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
EmptyPill()
|
||||
ShelfPill()
|
||||
EmptyPill()
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
|
||||
@@ -243,6 +243,16 @@ final class AppState: ObservableObject {
|
||||
|
||||
// Dropped file (set during upload flow)
|
||||
@Published var droppedFile: DroppedFile? = nil
|
||||
/// Original URLs of the last drop (all of them), for "На полку".
|
||||
var lastDroppedURLs: [URL] = []
|
||||
|
||||
/// Puts the last dropped files on the shelf and goes back home.
|
||||
func shelveLastDrop() {
|
||||
FileShelf.shared.add(lastDroppedURLs)
|
||||
SoundEngine.shared.play("pop")
|
||||
NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.proud)
|
||||
view = .overview
|
||||
}
|
||||
|
||||
// Short note message (shown in NoteView)
|
||||
@Published var noteMessage: String? = nil
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Destructive command warning
|
||||
//
|
||||
// Looks at a permission request (tool + tool_input) and returns short Russian reasons
|
||||
// when it can destroy data or history, e.g. ["удаляет файлы рекурсивно"]. The approval
|
||||
// card shows them in red and hides "Всегда" so a dangerous rule is never saved by one click.
|
||||
// Heuristics only: an empty result does not mean the command is safe.
|
||||
|
||||
enum DangerCheck {
|
||||
struct Rule {
|
||||
let pattern: String
|
||||
let reason: String
|
||||
}
|
||||
|
||||
/// Shell command rules (case-insensitive regex, matched against the whole command).
|
||||
static let shellRules: [Rule] = [
|
||||
Rule(pattern: #"\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r|-r\b.*-f\b|-f\b.*-r\b|--recursive|-r\b|-R\b)"#,
|
||||
reason: "удаляет файлы рекурсивно"),
|
||||
Rule(pattern: #"\brm\s+.*(\s|^)(/|~|\$HOME|\*)(\s|$)"#, reason: "удаляет корень, домашнюю папку или всё подряд"),
|
||||
Rule(pattern: #"\bgit\s+push\b.*(--force\b|--force-with-lease\b|\s-f\b|\s\+\S)"#, reason: "перезаписывает историю на сервере (force push)"),
|
||||
Rule(pattern: #"\bgit\s+reset\s+--hard\b"#, reason: "выбрасывает несохранённые изменения (reset --hard)"),
|
||||
Rule(pattern: #"\bgit\s+clean\s+-[a-z]*f"#, reason: "удаляет неотслеживаемые файлы (git clean)"),
|
||||
Rule(pattern: #"\bgit\s+(checkout|restore)\s+(--\s+)?\.(\s|$)"#, reason: "откатывает все изменения в рабочей папке"),
|
||||
Rule(pattern: #"\bgit\s+branch\s+-D\b"#, reason: "удаляет ветку без проверки слияния"),
|
||||
Rule(pattern: #"\bgit\s+(rebase|filter-branch|filter-repo)\b"#, reason: "переписывает историю git"),
|
||||
Rule(pattern: #"\bdrop\s+(table|database|schema|index|view)\b"#, reason: "удаляет таблицу или базу (DROP)"),
|
||||
Rule(pattern: #"\btruncate\s+(table\s+)?\w"#, reason: "очищает таблицу (TRUNCATE)"),
|
||||
Rule(pattern: #"\bdelete\s+from\s+\w+(\s*;|\s*$|\s*"|\s*')"#, reason: "удаляет все строки (DELETE без WHERE)"),
|
||||
Rule(pattern: #"\bupdate\s+\w+\s+set\b(?!.*\bwhere\b)"#, reason: "меняет все строки (UPDATE без WHERE)"),
|
||||
Rule(pattern: #"\b(mkfs|diskutil\s+(erase|zero|reformat|partition)|fdisk|newfs)"#, reason: "форматирует диск"),
|
||||
Rule(pattern: #"\bdd\s+.*\bof="#, reason: "пишет напрямую на устройство (dd)"),
|
||||
Rule(pattern: #"\bchmod\s+(-R\s+)?(0?777|a\+rwx)"#, reason: "открывает файлы всем на запись"),
|
||||
Rule(pattern: #"\b(chmod|chown)\s+-R\b.*(\s/|\s~)"#, reason: "меняет права рекурсивно в системной или домашней папке"),
|
||||
Rule(pattern: #"\bsudo\b"#, reason: "выполняется с правами администратора (sudo)"),
|
||||
Rule(pattern: #"(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|python3?)\b"#, reason: "скачивает и сразу запускает скрипт из интернета"),
|
||||
Rule(pattern: #":\(\)\s*\{\s*:\|:&\s*\};:"#, reason: "fork-бомба"),
|
||||
Rule(pattern: #"\b(kill|pkill|killall)\s+(-9\s+)?(-1\b|1\b|Finder|Dock|WindowServer|loginwindow)"#, reason: "убивает системные процессы"),
|
||||
Rule(pattern: #"\b(shutdown|reboot|halt)\b"#, reason: "выключает или перезагружает компьютер"),
|
||||
Rule(pattern: #"\bdocker\s+(system\s+prune|volume\s+(rm|prune)|rm\s+-f)"#, reason: "удаляет контейнеры или тома Docker"),
|
||||
Rule(pattern: #"\bkubectl\s+delete\b"#, reason: "удаляет ресурсы в Kubernetes"),
|
||||
Rule(pattern: #"\bterraform\s+(destroy|apply\s+.*-auto-approve)"#, reason: "меняет или уничтожает инфраструктуру"),
|
||||
Rule(pattern: #"\b(npm|pnpm|yarn)\s+publish\b|\bgh\s+release\s+create\b"#, reason: "публикует наружу"),
|
||||
Rule(pattern: #"\bgh\s+repo\s+(delete|edit\s+.*--visibility\s+public)"#, reason: "удаляет репозиторий или делает его публичным"),
|
||||
Rule(pattern: #">\s*(/etc/|/dev/sd|/dev/disk|~/\.ssh/|~/\.zshrc|~/\.bashrc)"#, reason: "перезаписывает системный или личный конфиг"),
|
||||
Rule(pattern: #"\bfind\b.*\s-delete\b|\bfind\b.*-exec\s+rm\b"#, reason: "удаляет найденные файлы пачкой"),
|
||||
Rule(pattern: #"\b(security\s+delete-|defaults\s+delete\b|launchctl\s+(unload|remove|bootout))"#, reason: "меняет системные настройки macOS"),
|
||||
]
|
||||
|
||||
/// Paths that are dangerous to write or edit.
|
||||
static let sensitivePaths: [(pattern: String, reason: String)] = [
|
||||
(#"(^|/)\.env(\.|$)"#, "правит секреты (.env)"),
|
||||
(#"/\.ssh/"#, "правит SSH-ключи или конфиг"),
|
||||
(#"^/(etc|System|Library|usr|bin|sbin)/"#, "пишет в системную папку"),
|
||||
(#"/\.claude/settings(\.local)?\.json$"#, "меняет настройки Claude Code"),
|
||||
(#"/\.(zshrc|bashrc|zprofile|bash_profile|gitconfig)$"#, "правит конфиг оболочки или git"),
|
||||
(#"/\.git/"#, "правит внутренности репозитория (.git)"),
|
||||
(#"(id_rsa|id_ed25519|\.pem|\.key|credentials|secrets?)(\.|$)"#, "трогает ключи или секреты"),
|
||||
]
|
||||
|
||||
static func reasons(tool: String, input: [String: Any]) -> [String] {
|
||||
var out: [String] = []
|
||||
if let command = input["command"] as? String {
|
||||
for rule in shellRules where matches(rule.pattern, command) && !out.contains(rule.reason) {
|
||||
out.append(rule.reason)
|
||||
}
|
||||
}
|
||||
let fileTools: Set<String> = ["Write", "Edit", "MultiEdit", "NotebookEdit"]
|
||||
if fileTools.contains(tool) {
|
||||
let path = (input["file_path"] as? String ?? input["notebook_path"] as? String ?? "")
|
||||
let expanded = (path as NSString).expandingTildeInPath
|
||||
for (pattern, reason) in sensitivePaths where matches(pattern, expanded) && !out.contains(reason) {
|
||||
out.append(reason)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
static func matches(_ pattern: String, _ text: String) -> Bool {
|
||||
text.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil
|
||||
}
|
||||
|
||||
/// What the approval card shows for non-shell tools: "Write · ~/proj/.env".
|
||||
static func describe(tool: String, input: [String: Any]) -> String {
|
||||
if let command = input["command"] as? String { return command }
|
||||
if let path = (input["file_path"] ?? input["notebook_path"] ?? input["path"]) as? String {
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
let short = path.hasPrefix(home) ? "~" + path.dropFirst(home.count) : path
|
||||
return "\(tool) · \(short)"
|
||||
}
|
||||
if let url = input["url"] as? String { return "\(tool) · \(url)" }
|
||||
return tool
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ enum FileDropHandler {
|
||||
static func handle(urls: [URL], state: AppState) async {
|
||||
guard let url = urls.first else { return }
|
||||
let name = url.lastPathComponent
|
||||
state.lastDroppedURLs = urls
|
||||
|
||||
// Start animation immediately — do NOT block on file copy.
|
||||
// Use original URL first; swap to inbox copy once background copy finishes.
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// MARK: - File shelf
|
||||
//
|
||||
// Drop files on the notch → "На полку": they wait in the top-left slot of the home
|
||||
// view. Drag an icon out to put the file anywhere, click to open it, right-click to
|
||||
// reveal or remove. The shelf keeps references (paths), not copies, and survives restarts;
|
||||
// files that no longer exist are dropped silently.
|
||||
|
||||
struct ShelfItem: Identifiable, Codable, Equatable {
|
||||
var id = UUID()
|
||||
var path: String
|
||||
var addedAt = Date()
|
||||
|
||||
var url: URL { URL(fileURLWithPath: path) }
|
||||
var name: String { url.lastPathComponent }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class FileShelf: ObservableObject {
|
||||
static let shared = FileShelf()
|
||||
static let maxItems = 12
|
||||
private let key = "fileShelf"
|
||||
|
||||
@Published private(set) var items: [ShelfItem] = []
|
||||
|
||||
private init() {
|
||||
if let data = UserDefaults.standard.data(forKey: key),
|
||||
let saved = try? JSONDecoder().decode([ShelfItem].self, from: data) {
|
||||
items = saved.filter { FileManager.default.fileExists(atPath: $0.path) }
|
||||
}
|
||||
}
|
||||
|
||||
func add(_ urls: [URL]) {
|
||||
for url in urls where !items.contains(where: { $0.path == url.path }) {
|
||||
items.insert(ShelfItem(path: url.path), at: 0)
|
||||
}
|
||||
if items.count > Self.maxItems { items = Array(items.prefix(Self.maxItems)) }
|
||||
save()
|
||||
}
|
||||
|
||||
func remove(_ item: ShelfItem) {
|
||||
items.removeAll { $0.id == item.id }
|
||||
save()
|
||||
}
|
||||
|
||||
func clear() {
|
||||
items = []
|
||||
save()
|
||||
}
|
||||
|
||||
/// Drops entries whose file was moved or deleted.
|
||||
func prune() {
|
||||
let alive = items.filter { FileManager.default.fileExists(atPath: $0.path) }
|
||||
if alive != items { items = alive; save() }
|
||||
}
|
||||
|
||||
private func save() {
|
||||
if let data = try? JSONEncoder().encode(items) {
|
||||
UserDefaults.standard.set(data, forKey: key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shelf pill (home view, top-left slot)
|
||||
|
||||
struct ShelfPill: View {
|
||||
@ObservedObject private var shelf = FileShelf.shared
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if shelf.items.isEmpty {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(Color.white.opacity(0.08), style: StrokeStyle(lineWidth: 1, dash: [4, 4]))
|
||||
Text("полка · брось файл на чёлку")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundColor(Color(hex: "#4A4E55"))
|
||||
} else {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(Color(hex: "#0E0F11"))
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.10), lineWidth: 1)
|
||||
HStack(spacing: 6) {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(shelf.items) { item in
|
||||
ShelfIcon(item: item)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 2)
|
||||
}
|
||||
Button {
|
||||
shelf.clear()
|
||||
SoundEngine.shared.play("close")
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundColor(Color(hex: "#5F646D"))
|
||||
.frame(width: 16, height: 16)
|
||||
.background(Circle().fill(Color.white.opacity(0.06)))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Очистить полку")
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onAppear { shelf.prune() }
|
||||
}
|
||||
}
|
||||
|
||||
struct ShelfIcon: View {
|
||||
let item: ShelfItem
|
||||
@State private var isHovered = false
|
||||
|
||||
var body: some View {
|
||||
Image(nsImage: NSWorkspace.shared.icon(forFile: item.path))
|
||||
.resizable()
|
||||
.interpolation(.high)
|
||||
.frame(width: 28, height: 28)
|
||||
.padding(2)
|
||||
.background(RoundedRectangle(cornerRadius: 6).fill(Color.white.opacity(isHovered ? 0.10 : 0)))
|
||||
.scaleEffect(isHovered ? 1.08 : 1)
|
||||
.onHover { h in withAnimation(.easeOut(duration: 0.12)) { isHovered = h } }
|
||||
.help(item.name)
|
||||
.onTapGesture { NSWorkspace.shared.open(item.url) }
|
||||
.onDrag {
|
||||
SoundEngine.shared.play("pop")
|
||||
return NSItemProvider(contentsOf: item.url) ?? NSItemProvider()
|
||||
}
|
||||
.contextMenu {
|
||||
Button("Открыть") { NSWorkspace.shared.open(item.url) }
|
||||
Button("Показать в Finder") { NSWorkspace.shared.activateFileViewerSelecting([item.url]) }
|
||||
Divider()
|
||||
Button("Убрать с полки") { FileShelf.shared.remove(item) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -672,7 +672,8 @@ final class HookServer: @unchecked Sendable {
|
||||
return
|
||||
}
|
||||
|
||||
let command = toolInput["command"] as? String ?? tool
|
||||
let command = DangerCheck.describe(tool: tool, input: toolInput)
|
||||
let danger = DangerCheck.reasons(tool: tool, input: toolInput)
|
||||
|
||||
if pendingApprovalFD >= 0 {
|
||||
// Displace the previous request: write "ask" then cancel its source.
|
||||
@@ -692,7 +693,9 @@ final class HookServer: @unchecked Sendable {
|
||||
upsertWorkspaceTask(id: pillId, projectName: projectName, cwd: cwd)
|
||||
state.updateTask(id: pillId, state: .approval)
|
||||
state.pendingApproval = ApprovalInfo(sessionId: sessionId, tool: tool,
|
||||
command: command, inputKey: inputKey, pillId: pillId)
|
||||
command: command, inputKey: inputKey, pillId: pillId,
|
||||
danger: danger)
|
||||
if !danger.isEmpty { NotificationCenter.default.post(name: .triggerEmote, object: BotEmote.surprised) }
|
||||
state.isPinned = true
|
||||
SoundEngine.shared.play("approval")
|
||||
|
||||
|
||||
@@ -298,11 +298,25 @@ struct ApprovalView: View {
|
||||
var approval: ApprovalInfo? { state.pendingApproval }
|
||||
|
||||
var body: some View {
|
||||
let danger = approval?.danger ?? []
|
||||
ZStack {
|
||||
CardBackground(wash: .amber)
|
||||
CardBackground(wash: danger.isEmpty ? .amber : .red)
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
AgentWho(task: state.focusTask, label: "просит разрешение")
|
||||
CodeBlock(text: approval?.command ?? approval?.tool ?? "…")
|
||||
if !danger.isEmpty {
|
||||
HStack(alignment: .top, spacing: 5) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
Text("Опасно: " + danger.joined(separator: "; "))
|
||||
.font(.system(size: 10.5, weight: .semibold))
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.foregroundColor(Color(hex: "#FF6B76"))
|
||||
.padding(.horizontal, 8).padding(.vertical, 3)
|
||||
.background(RoundedRectangle(cornerRadius: 6).fill(Color(hex: "#F4505E").opacity(0.14)))
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
SecondaryButton("Запретить") {
|
||||
HookServer.shared.sendApprovalDecision("deny")
|
||||
@@ -310,8 +324,9 @@ struct ApprovalView: View {
|
||||
PrimaryButton("Разрешить") {
|
||||
HookServer.shared.sendApprovalDecision("allow")
|
||||
}
|
||||
// Codex rejects updatedPermissions, so "Always" is not offered
|
||||
if approval?.pillId != "agent_codex" {
|
||||
// Codex rejects updatedPermissions, so "Always" is not offered.
|
||||
// Never for a dangerous command: one click must not save a destructive rule.
|
||||
if approval?.pillId != "agent_codex" && danger.isEmpty {
|
||||
SecondaryButton("Всегда") {
|
||||
HookServer.shared.sendApprovalDecision("always")
|
||||
}
|
||||
@@ -922,6 +937,7 @@ struct ChooseView: View {
|
||||
HStack(spacing: 8) {
|
||||
PrimaryButton("Задать вопрос") { state.view = .prompt }
|
||||
SecondaryButton("Отправить почтой") { state.view = .mail }
|
||||
SecondaryButton("На полку") { state.shelveLastDrop() }
|
||||
}
|
||||
}
|
||||
.padding(.leading, 98)
|
||||
|
||||
@@ -80,6 +80,21 @@ struct UploadCanvasView: View {
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 120, height: 26)
|
||||
.position(x: 290 + 60, y: 113 + 13) // center = (350, 126)
|
||||
|
||||
// Tertiary: put on the shelf
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.22)) { state.shelveLastDrop() }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) {
|
||||
UploadSequenceEngine.shared.deactivate()
|
||||
}
|
||||
} label: {
|
||||
Color.clear
|
||||
.frame(width: 96, height: 26)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(width: 96, height: 26)
|
||||
.position(x: 418 + 48, y: 113 + 13) // center = (466, 126)
|
||||
}
|
||||
.opacity(f.chooseAlpha)
|
||||
.allowsHitTesting(f.chooseAlpha > 0.5)
|
||||
@@ -288,6 +303,14 @@ struct UploadCanvasView: View {
|
||||
.font(.system(size:12.5, weight:.medium))
|
||||
.foregroundColor(Color(hex:"#F1F2F4"))
|
||||
cCtx.draw(btn2, at: CGPoint(x:350, y:126), anchor: .center)
|
||||
|
||||
// Tertiary button: shelf
|
||||
cCtx.fill(roundedRect(CGRect(x:418,y:113,width:96,height:26), r:13),
|
||||
with: .color(Color.white.opacity(0.09)))
|
||||
let btn3 = Text("На полку")
|
||||
.font(.system(size:12.5, weight:.medium))
|
||||
.foregroundColor(Color(hex:"#F1F2F4"))
|
||||
cCtx.draw(btn3, at: CGPoint(x:466, y:126), anchor: .center)
|
||||
}
|
||||
|
||||
// MARK: - Mochi (superellipse body + eyes + mouth)
|
||||
|
||||
@@ -38,6 +38,8 @@ struct ApprovalInfo: Sendable {
|
||||
var inputKey: String
|
||||
/// Pill that owns this approval: "integration_claude", "agent_cursor", or "agent_codex".
|
||||
var pillId: String
|
||||
/// Why this request looks destructive (DangerCheck), empty when nothing matched.
|
||||
var danger: [String] = []
|
||||
}
|
||||
|
||||
// MARK: - Pill badge (shown on pill edge when non-focused task has an alert)
|
||||
|
||||
Reference in New Issue
Block a user