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%
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