// // TerminalDrawerView.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // The bottom Activity/Terminal drawer with three tabs: // Live — color-coded streaming feed of raw command execution (SF Mono, // append-only, auto-scrolling). // Audit — the workspace's append-only audit log (time, kind, payload, // ruling, exit code). // Files — the workspace tree with created/modified badges, click to // preview, reveal in Finder. // import SwiftUI struct TerminalDrawerView: View { @ObservedObject var controller: RunController @EnvironmentObject private var uiState: AppUIState enum Tab: String, CaseIterable, Identifiable { case live = "Live" case audit = "Audit Log" case files = "Files" var id: String { rawValue } } @State private var tab: Tab = .live @State private var previewedFile: WorkspaceFileEntry? var body: some View { VStack(spacing: 0) { tabBar ZyquoHairline() switch tab { case .live: liveFeed case .audit: auditTable case .files: filesList } } .frame(height: ZyquoMetrics.terminalDrawerHeight) .background(ZyquoColor.surfaceSecondary.opacity(0.4)) .onChange(of: tab) { newTab in switch newTab { case .audit: controller.refreshAudit() case .files: controller.refreshWorkspaceState() case .live: break } } // ⌘⇧A / palette: land on the Audit Log tab. .onChange(of: uiState.auditLogRequest) { _ in withAnimation(ZyquoMotion.picker) { tab = .audit } } .sheet(item: $previewedFile) { entry in FilePreviewSheet(entry: entry, workspaceRoot: controller.workspaceRoot) } } // MARK: - Tab bar private var tabBar: some View { HStack(spacing: ZyquoSpacing.xs) { ForEach(Tab.allCases) { candidate in Button { withAnimation(ZyquoMotion.picker) { tab = candidate } } label: { Text(candidate.rawValue) .font(tab == candidate ? ZyquoFont.bodyEmphasis(size: 11.5) : ZyquoFont.body(size: 11.5)) .foregroundStyle(tab == candidate ? ZyquoColor.textPrimary : ZyquoColor.textSecondary) .padding(.horizontal, ZyquoSpacing.xs) .padding(.vertical, 3) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(tab == candidate ? ZyquoColor.surface : .clear) ) } .buttonStyle(.plain) } Spacer(minLength: 0) if let root = controller.workspaceRoot { Text(root.lastPathComponent) .font(ZyquoFont.code(size: 10.5)) .foregroundStyle(ZyquoColor.textTertiary) .lineLimit(1) .help(root.path) } } .padding(.horizontal, ZyquoSpacing.sm) .padding(.vertical, ZyquoSpacing.xxs) } // MARK: - Live feed private static let liveBottomID = "terminal-live-bottom" private var liveFeed: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 1) { if controller.terminalLines.isEmpty { Text("Command output streams here while the agent works.") .font(ZyquoFont.code(size: 11.5)) .foregroundStyle(ZyquoColor.textTertiary) .padding(.top, ZyquoSpacing.xs) } ForEach(controller.terminalLines) { line in Text(line.text) .font(ZyquoFont.code(size: 11.5)) .foregroundStyle(color(for: line.kind)) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) .frame(maxWidth: .infinity, alignment: .leading) } Color.clear.frame(height: 1).id(Self.liveBottomID) } .padding(.horizontal, ZyquoSpacing.sm) .padding(.vertical, ZyquoSpacing.xs) } .onChange(of: controller.terminalLines.count) { _ in proxy.scrollTo(Self.liveBottomID, anchor: .bottom) } } } private func color(for kind: TerminalLine.Kind) -> Color { switch kind { case .command: return ZyquoColor.accent case .stdout: return ZyquoColor.textPrimary case .stderr: return ZyquoColor.danger case .note: return ZyquoColor.textSecondary case .meta: return ZyquoColor.textTertiary } } // MARK: - Audit tab private var auditTable: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { if controller.auditEntries.isEmpty { Text("Every executed action is recorded here — nothing the agent does is invisible.") .font(ZyquoFont.body(size: 12)) .foregroundStyle(ZyquoColor.textTertiary) .padding(ZyquoSpacing.sm) } ForEach(controller.auditEntries) { entry in auditRow(entry) ZyquoHairline() } } } .onAppear { controller.refreshAudit() } } private func auditRow(_ entry: AuditEntry) -> some View { HStack(alignment: .firstTextBaseline, spacing: ZyquoSpacing.sm) { Text(entry.timestamp, format: .dateTime.hour().minute().second()) .font(ZyquoFont.code(size: 10.5)) .foregroundStyle(ZyquoColor.textTertiary) .frame(width: 64, alignment: .leading) HStack(spacing: ZyquoSpacing.xxs) { Image(systemName: toolSymbolName(entry.actionKind)) .font(.system(size: 9)) Text(entry.actionKind) .font(ZyquoFont.code(size: 10.5)) } .foregroundStyle(ZyquoColor.accent) .frame(width: 84, alignment: .leading) Text(entry.payload) .font(ZyquoFont.code(size: 10.5)) .foregroundStyle(ZyquoColor.textPrimary) .lineLimit(1) .truncationMode(.tail) .help(entry.payload) Spacer(minLength: 0) Text(entry.ruling) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) if let code = entry.exitCode { Text("exit \(code)") .font(ZyquoFont.caption) .foregroundStyle(code == 0 ? ZyquoColor.success : ZyquoColor.danger) } } .padding(.horizontal, ZyquoSpacing.sm) .padding(.vertical, ZyquoSpacing.xxs) } // MARK: - Files tab private var filesList: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { if controller.workspaceFiles.isEmpty { Text("Files the agent creates or modifies in the workspace appear here.") .font(ZyquoFont.body(size: 12)) .foregroundStyle(ZyquoColor.textTertiary) .padding(ZyquoSpacing.sm) } ForEach(controller.workspaceFiles) { entry in fileRow(entry) } } .padding(.vertical, ZyquoSpacing.xxs) } .onAppear { controller.refreshWorkspaceState() } } private func fileRow(_ entry: WorkspaceFileEntry) -> some View { Button { previewedFile = entry } label: { HStack(spacing: ZyquoSpacing.xs) { Image(systemName: "doc.text") .font(.system(size: 11)) .foregroundStyle(ZyquoColor.textSecondary) Text(entry.path) .font(ZyquoFont.code(size: 11.5)) .foregroundStyle(ZyquoColor.textPrimary) .lineLimit(1) ZyquoBadge( text: entry.status == .created ? "created" : "modified", color: entry.status == .created ? ZyquoColor.success : ZyquoColor.warning ) Spacer(minLength: 0) Text(entry.lastTouched, format: .relative(presentation: .named)) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) Button { reveal(entry) } label: { Image(systemName: "arrow.right.circle") .font(.system(size: 11)) .foregroundStyle(ZyquoColor.textSecondary) } .buttonStyle(.plain) .help("Reveal in Finder") } .padding(.horizontal, ZyquoSpacing.sm) .padding(.vertical, ZyquoSpacing.xxs) .contentShape(Rectangle()) } .buttonStyle(.plain) .zyquoHoverHighlight() } private func reveal(_ entry: WorkspaceFileEntry) { guard let root = controller.workspaceRoot else { return } let url = root.appendingPathComponent(entry.path) NSWorkspace.shared.activateFileViewerSelecting([url]) } } // MARK: - File preview sheet /// Quick preview of a workspace file (text contents, capped) with a reveal /// button. struct FilePreviewSheet: View { let entry: WorkspaceFileEntry let workspaceRoot: URL? @Environment(\.dismiss) private var dismiss /// Preview cap: files larger than this show a truncated head. private static let previewByteLimit = 200_000 var body: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { HStack(spacing: ZyquoSpacing.xs) { Image(systemName: "doc.text") .font(.system(size: 13)) .foregroundStyle(ZyquoColor.accent) Text(entry.path) .font(ZyquoFont.bodyEmphasis()) .foregroundStyle(ZyquoColor.textPrimary) .lineLimit(1) Spacer(minLength: 0) Button("Reveal in Finder") { if let root = workspaceRoot { NSWorkspace.shared.activateFileViewerSelecting([root.appendingPathComponent(entry.path)]) } } Button("Done") { dismiss() } .keyboardShortcut(.defaultAction) } ScrollView([.vertical, .horizontal]) { Text(contents) .font(ZyquoFont.code()) .foregroundStyle(ZyquoColor.textPrimary) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) .padding(ZyquoSpacing.sm) } .background( RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) .fill(ZyquoColor.surfaceSecondary) ) } .padding(ZyquoSpacing.md) .frame( width: ZyquoMetrics.maxMessageColumnWidth, height: ZyquoMetrics.settingsHeight ) .background(ZyquoColor.surface) } private var contents: String { guard let root = workspaceRoot else { return "(workspace unavailable)" } let url = root.appendingPathComponent(entry.path) guard let data = try? Data(contentsOf: url) else { return "(could not read file)" } if data.count > Self.previewByteLimit { let head = String(decoding: data.prefix(Self.previewByteLimit), as: UTF8.self) return head + "\n… [truncated — open in Finder for the full file]" } return String(decoding: data, as: UTF8.self) } }