SPB Git

spb/zyquo-agent Public MIT

The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.

Swift 94.7% Shell 4.1% Python 0.7% Makefile 0.5%
12.1 KB · 320 lines swift
Raw Blame History
1//2//  TerminalDrawerView.swift3//  Zyquo Agent4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  The bottom Activity/Terminal drawer with three tabs:9//    Live  — color-coded streaming feed of raw command execution (SF Mono,10//            append-only, auto-scrolling).11//    Audit — the workspace's append-only audit log (time, kind, payload,12//            ruling, exit code).13//    Files — the workspace tree with created/modified badges, click to14//            preview, reveal in Finder.15//1617import SwiftUI1819struct TerminalDrawerView: View {20    @ObservedObject var controller: RunController21    @EnvironmentObject private var uiState: AppUIState2223    enum Tab: String, CaseIterable, Identifiable {24        case live = "Live"25        case audit = "Audit Log"26        case files = "Files"27        var id: String { rawValue }28    }2930    @State private var tab: Tab = .live31    @State private var previewedFile: WorkspaceFileEntry?3233    var body: some View {34        VStack(spacing: 0) {35            tabBar36            ZyquoHairline()37            switch tab {38            case .live: liveFeed39            case .audit: auditTable40            case .files: filesList41            }42        }43        .frame(height: ZyquoMetrics.terminalDrawerHeight)44        .background(ZyquoColor.surfaceSecondary.opacity(0.4))45        .onChange(of: tab) { newTab in46            switch newTab {47            case .audit: controller.refreshAudit()48            case .files: controller.refreshWorkspaceState()49            case .live: break50            }51        }52        // ⌘⇧A / palette: land on the Audit Log tab.53        .onChange(of: uiState.auditLogRequest) { _ in54            withAnimation(ZyquoMotion.picker) { tab = .audit }55        }56        .sheet(item: $previewedFile) { entry in57            FilePreviewSheet(entry: entry, workspaceRoot: controller.workspaceRoot)58        }59    }6061    // MARK: - Tab bar6263    private var tabBar: some View {64        HStack(spacing: ZyquoSpacing.xs) {65            ForEach(Tab.allCases) { candidate in66                Button {67                    withAnimation(ZyquoMotion.picker) { tab = candidate }68                } label: {69                    Text(candidate.rawValue)70                        .font(tab == candidate ? ZyquoFont.bodyEmphasis(size: 11.5) : ZyquoFont.body(size: 11.5))71                        .foregroundStyle(tab == candidate ? ZyquoColor.textPrimary : ZyquoColor.textSecondary)72                        .padding(.horizontal, ZyquoSpacing.xs)73                        .padding(.vertical, 3)74                        .background(75                            RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)76                                .fill(tab == candidate ? ZyquoColor.surface : .clear)77                        )78                }79                .buttonStyle(.plain)80            }81            Spacer(minLength: 0)82            if let root = controller.workspaceRoot {83                Text(root.lastPathComponent)84                    .font(ZyquoFont.code(size: 10.5))85                    .foregroundStyle(ZyquoColor.textTertiary)86                    .lineLimit(1)87                    .help(root.path)88            }89        }90        .padding(.horizontal, ZyquoSpacing.sm)91        .padding(.vertical, ZyquoSpacing.xxs)92    }9394    // MARK: - Live feed9596    private static let liveBottomID = "terminal-live-bottom"9798    private var liveFeed: some View {99        ScrollViewReader { proxy in100            ScrollView {101                LazyVStack(alignment: .leading, spacing: 1) {102                    if controller.terminalLines.isEmpty {103                        Text("Command output streams here while the agent works.")104                            .font(ZyquoFont.code(size: 11.5))105                            .foregroundStyle(ZyquoColor.textTertiary)106                            .padding(.top, ZyquoSpacing.xs)107                    }108                    ForEach(controller.terminalLines) { line in109                        Text(line.text)110                            .font(ZyquoFont.code(size: 11.5))111                            .foregroundStyle(color(for: line.kind))112                            .textSelection(.enabled)113                            .fixedSize(horizontal: false, vertical: true)114                            .frame(maxWidth: .infinity, alignment: .leading)115                    }116                    Color.clear.frame(height: 1).id(Self.liveBottomID)117                }118                .padding(.horizontal, ZyquoSpacing.sm)119                .padding(.vertical, ZyquoSpacing.xs)120            }121            .onChange(of: controller.terminalLines.count) { _ in122                proxy.scrollTo(Self.liveBottomID, anchor: .bottom)123            }124        }125    }126127    private func color(for kind: TerminalLine.Kind) -> Color {128        switch kind {129        case .command: return ZyquoColor.accent130        case .stdout: return ZyquoColor.textPrimary131        case .stderr: return ZyquoColor.danger132        case .note: return ZyquoColor.textSecondary133        case .meta: return ZyquoColor.textTertiary134        }135    }136137    // MARK: - Audit tab138139    private var auditTable: some View {140        ScrollView {141            LazyVStack(alignment: .leading, spacing: 0) {142                if controller.auditEntries.isEmpty {143                    Text("Every executed action is recorded here — nothing the agent does is invisible.")144                        .font(ZyquoFont.body(size: 12))145                        .foregroundStyle(ZyquoColor.textTertiary)146                        .padding(ZyquoSpacing.sm)147                }148                ForEach(controller.auditEntries) { entry in149                    auditRow(entry)150                    ZyquoHairline()151                }152            }153        }154        .onAppear { controller.refreshAudit() }155    }156157    private func auditRow(_ entry: AuditEntry) -> some View {158        HStack(alignment: .firstTextBaseline, spacing: ZyquoSpacing.sm) {159            Text(entry.timestamp, format: .dateTime.hour().minute().second())160                .font(ZyquoFont.code(size: 10.5))161                .foregroundStyle(ZyquoColor.textTertiary)162                .frame(width: 64, alignment: .leading)163            HStack(spacing: ZyquoSpacing.xxs) {164                Image(systemName: toolSymbolName(entry.actionKind))165                    .font(.system(size: 9))166                Text(entry.actionKind)167                    .font(ZyquoFont.code(size: 10.5))168            }169            .foregroundStyle(ZyquoColor.accent)170            .frame(width: 84, alignment: .leading)171            Text(entry.payload)172                .font(ZyquoFont.code(size: 10.5))173                .foregroundStyle(ZyquoColor.textPrimary)174                .lineLimit(1)175                .truncationMode(.tail)176                .help(entry.payload)177            Spacer(minLength: 0)178            Text(entry.ruling)179                .font(ZyquoFont.caption)180                .foregroundStyle(ZyquoColor.textTertiary)181            if let code = entry.exitCode {182                Text("exit \(code)")183                    .font(ZyquoFont.caption)184                    .foregroundStyle(code == 0 ? ZyquoColor.success : ZyquoColor.danger)185            }186        }187        .padding(.horizontal, ZyquoSpacing.sm)188        .padding(.vertical, ZyquoSpacing.xxs)189    }190191    // MARK: - Files tab192193    private var filesList: some View {194        ScrollView {195            LazyVStack(alignment: .leading, spacing: 0) {196                if controller.workspaceFiles.isEmpty {197                    Text("Files the agent creates or modifies in the workspace appear here.")198                        .font(ZyquoFont.body(size: 12))199                        .foregroundStyle(ZyquoColor.textTertiary)200                        .padding(ZyquoSpacing.sm)201                }202                ForEach(controller.workspaceFiles) { entry in203                    fileRow(entry)204                }205            }206            .padding(.vertical, ZyquoSpacing.xxs)207        }208        .onAppear { controller.refreshWorkspaceState() }209    }210211    private func fileRow(_ entry: WorkspaceFileEntry) -> some View {212        Button {213            previewedFile = entry214        } label: {215            HStack(spacing: ZyquoSpacing.xs) {216                Image(systemName: "doc.text")217                    .font(.system(size: 11))218                    .foregroundStyle(ZyquoColor.textSecondary)219                Text(entry.path)220                    .font(ZyquoFont.code(size: 11.5))221                    .foregroundStyle(ZyquoColor.textPrimary)222                    .lineLimit(1)223                ZyquoBadge(224                    text: entry.status == .created ? "created" : "modified",225                    color: entry.status == .created ? ZyquoColor.success : ZyquoColor.warning226                )227                Spacer(minLength: 0)228                Text(entry.lastTouched, format: .relative(presentation: .named))229                    .font(ZyquoFont.caption)230                    .foregroundStyle(ZyquoColor.textTertiary)231                Button {232                    reveal(entry)233                } label: {234                    Image(systemName: "arrow.right.circle")235                        .font(.system(size: 11))236                        .foregroundStyle(ZyquoColor.textSecondary)237                }238                .buttonStyle(.plain)239                .help("Reveal in Finder")240            }241            .padding(.horizontal, ZyquoSpacing.sm)242            .padding(.vertical, ZyquoSpacing.xxs)243            .contentShape(Rectangle())244        }245        .buttonStyle(.plain)246        .zyquoHoverHighlight()247    }248249    private func reveal(_ entry: WorkspaceFileEntry) {250        guard let root = controller.workspaceRoot else { return }251        let url = root.appendingPathComponent(entry.path)252        NSWorkspace.shared.activateFileViewerSelecting([url])253    }254}255256// MARK: - File preview sheet257258/// Quick preview of a workspace file (text contents, capped) with a reveal259/// button.260struct FilePreviewSheet: View {261    let entry: WorkspaceFileEntry262    let workspaceRoot: URL?263264    @Environment(\.dismiss) private var dismiss265266    /// Preview cap: files larger than this show a truncated head.267    private static let previewByteLimit = 200_000268269    var body: some View {270        VStack(alignment: .leading, spacing: ZyquoSpacing.sm) {271            HStack(spacing: ZyquoSpacing.xs) {272                Image(systemName: "doc.text")273                    .font(.system(size: 13))274                    .foregroundStyle(ZyquoColor.accent)275                Text(entry.path)276                    .font(ZyquoFont.bodyEmphasis())277                    .foregroundStyle(ZyquoColor.textPrimary)278                    .lineLimit(1)279                Spacer(minLength: 0)280                Button("Reveal in Finder") {281                    if let root = workspaceRoot {282                        NSWorkspace.shared.activateFileViewerSelecting([root.appendingPathComponent(entry.path)])283                    }284                }285                Button("Done") { dismiss() }286                    .keyboardShortcut(.defaultAction)287            }288            ScrollView([.vertical, .horizontal]) {289                Text(contents)290                    .font(ZyquoFont.code())291                    .foregroundStyle(ZyquoColor.textPrimary)292                    .textSelection(.enabled)293                    .frame(maxWidth: .infinity, alignment: .leading)294                    .padding(ZyquoSpacing.sm)295            }296            .background(297                RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)298                    .fill(ZyquoColor.surfaceSecondary)299            )300        }301        .padding(ZyquoSpacing.md)302        .frame(303            width: ZyquoMetrics.maxMessageColumnWidth,304            height: ZyquoMetrics.settingsHeight305        )306        .background(ZyquoColor.surface)307    }308309    private var contents: String {310        guard let root = workspaceRoot else { return "(workspace unavailable)" }311        let url = root.appendingPathComponent(entry.path)312        guard let data = try? Data(contentsOf: url) else { return "(could not read file)" }313        if data.count > Self.previewByteLimit {314            let head = String(decoding: data.prefix(Self.previewByteLimit), as: UTF8.self)315            return head + "\n… [truncated — open in Finder for the full file]"316        }317        return String(decoding: data, as: UTF8.self)318    }319}320