From e4d5bb0364b681c571bf2c74fc74a1aba24b1a4c Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Tue, 31 Mar 2026 11:55:35 -0400 Subject: [PATCH] Add session management: rename, delete, export, and stats bar Sessions browser enhancements: - Stats bar: total sessions, messages, DB size, per-platform counts - Right-click context menu on session rows: Rename, Export, Delete - Detail view actions menu (ellipsis button): same actions - Rename: sheet with text field, calls hermes sessions rename - Delete: confirmation dialog, calls hermes sessions delete --yes - Export single session: NSSavePanel, calls hermes sessions export - Export all: button in stats bar, exports everything to JSONL - Session ID shown in detail header for reference Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ViewModels/SessionsViewModel.swift | 140 ++++++++++++++++++ .../Sessions/Views/SessionDetailView.swift | 32 +++- .../Sessions/Views/SessionsView.swift | 92 +++++++++++- 3 files changed, 252 insertions(+), 12 deletions(-) diff --git a/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift index 72ab51b..6809a94 100644 --- a/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift +++ b/scarf/scarf/Features/Sessions/ViewModels/SessionsViewModel.swift @@ -1,4 +1,13 @@ import Foundation +import AppKit +import UniformTypeIdentifiers + +struct SessionStoreStats { + let totalSessions: Int + let totalMessages: Int + let databaseSize: String + let platformCounts: [(platform: String, count: Int)] +} @Observable final class SessionsViewModel { @@ -11,12 +20,20 @@ final class SessionsViewModel { var searchText = "" var searchResults: [HermesMessage] = [] var isSearching = false + var storeStats: SessionStoreStats? + + var renameSessionId: String? + var renameText = "" + var showRenameSheet = false + var showDeleteConfirmation = false + var deleteSessionId: String? func load() async { let opened = await dataService.open() guard opened else { return } sessions = await dataService.fetchSessions(limit: 500) sessionPreviews = await dataService.fetchSessionPreviews(limit: 500) + computeStats() } func previewFor(_ session: HermesSession) -> String { @@ -50,4 +67,127 @@ final class SessionsViewModel { func cleanup() async { await dataService.close() } + + // MARK: - Session Actions + + func beginRename(_ session: HermesSession) { + renameSessionId = session.id + renameText = previewFor(session) + showRenameSheet = true + } + + func confirmRename() { + guard let sessionId = renameSessionId else { return } + let title = renameText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { return } + let result = runHermes(["sessions", "rename", sessionId, title]) + if result.exitCode == 0 { + if let idx = sessions.firstIndex(where: { $0.id == sessionId }) { + sessions[idx] = HermesSession( + id: sessions[idx].id, source: sessions[idx].source, + userId: sessions[idx].userId, model: sessions[idx].model, + title: title, parentSessionId: sessions[idx].parentSessionId, + startedAt: sessions[idx].startedAt, endedAt: sessions[idx].endedAt, + endReason: sessions[idx].endReason, messageCount: sessions[idx].messageCount, + toolCallCount: sessions[idx].toolCallCount, inputTokens: sessions[idx].inputTokens, + outputTokens: sessions[idx].outputTokens, cacheReadTokens: sessions[idx].cacheReadTokens, + cacheWriteTokens: sessions[idx].cacheWriteTokens, + estimatedCostUSD: sessions[idx].estimatedCostUSD + ) + } + } + showRenameSheet = false + renameSessionId = nil + } + + func beginDelete(_ session: HermesSession) { + deleteSessionId = session.id + showDeleteConfirmation = true + } + + func confirmDelete() { + guard let sessionId = deleteSessionId else { return } + let result = runHermes(["sessions", "delete", "--yes", sessionId]) + if result.exitCode == 0 { + sessions.removeAll { $0.id == sessionId } + if selectedSession?.id == sessionId { + selectedSession = nil + messages = [] + } + computeStats() + } + showDeleteConfirmation = false + deleteSessionId = nil + } + + func exportSession(_ session: HermesSession) { + let panel = NSSavePanel() + panel.nameFieldStringValue = "\(session.id).jsonl" + panel.allowedContentTypes = [.json] + panel.canCreateDirectories = true + guard panel.runModal() == .OK, let url = panel.url else { return } + runHermes(["sessions", "export", url.path, "--session-id", session.id]) + } + + func exportAll() { + let panel = NSSavePanel() + panel.nameFieldStringValue = "hermes-sessions.jsonl" + panel.allowedContentTypes = [.json] + panel.canCreateDirectories = true + guard panel.runModal() == .OK, let url = panel.url else { return } + runHermes(["sessions", "export", url.path]) + } + + // MARK: - Stats + + private func computeStats() { + let totalMessages = sessions.reduce(0) { $0 + $1.messageCount } + + var platformCounts: [String: Int] = [:] + for s in sessions { + platformCounts[s.source, default: 0] += 1 + } + let sorted = platformCounts.sorted { $0.value > $1.value }.map { (platform: $0.key, count: $0.value) } + + let dbPath = HermesPaths.stateDB + let fileSize: String + if let attrs = try? FileManager.default.attributesOfItem(atPath: dbPath), + let size = attrs[.size] as? Int { + if size >= 1_048_576 { + fileSize = String(format: "%.1f MB", Double(size) / 1_048_576) + } else { + fileSize = String(format: "%.0f KB", Double(size) / 1_024) + } + } else { + fileSize = "unknown" + } + + storeStats = SessionStoreStats( + totalSessions: sessions.count, + totalMessages: totalMessages, + databaseSize: fileSize, + platformCounts: sorted + ) + } + + // MARK: - Hermes CLI + + @discardableResult + private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) { + let process = Process() + process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary) + process.arguments = arguments + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + return (output, process.terminationStatus) + } catch { + return ("", -1) + } + } } diff --git a/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift b/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift index 9454531..8459a34 100644 --- a/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift +++ b/scarf/scarf/Features/Sessions/Views/SessionDetailView.swift @@ -4,6 +4,9 @@ struct SessionDetailView: View { let session: HermesSession let messages: [HermesMessage] var preview: String? + var onRename: (() -> Void)? + var onExport: (() -> Void)? + var onDelete: (() -> Void)? var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -16,22 +19,41 @@ struct SessionDetailView: View { private var sessionHeader: some View { VStack(alignment: .leading, spacing: 6) { - Text(preview ?? session.displayTitle) - .font(.title3.bold()) + HStack { + Text(preview ?? session.displayTitle) + .font(.title3.bold()) + Spacer() + if onRename != nil || onExport != nil || onDelete != nil { + Menu { + if let onRename { Button("Rename...") { onRename() } } + if let onExport { Button("Export...") { onExport() } } + if let onDelete { + Divider() + Button("Delete...", role: .destructive) { onDelete() } + } + } label: { + Image(systemName: "ellipsis.circle") + .foregroundStyle(.secondary) + } + .menuStyle(.borderlessButton) + .fixedSize() + } + } HStack(spacing: 16) { Label(session.source, systemImage: session.sourceIcon) Label(session.model ?? "unknown", systemImage: "cpu") Label("\(session.messageCount) msgs", systemImage: "bubble.left") Label("\(session.toolCallCount) tools", systemImage: "wrench") - if let cost = session.estimatedCostUSD { - Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle") - } if let date = session.startedAt { Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar") } } .font(.caption) .foregroundStyle(.secondary) + Text(session.id) + .font(.caption2.monospaced()) + .foregroundStyle(.tertiary) + .textSelection(.enabled) } .padding() } diff --git a/scarf/scarf/Features/Sessions/Views/SessionsView.swift b/scarf/scarf/Features/Sessions/Views/SessionsView.swift index 44f904f..0dae57b 100644 --- a/scarf/scarf/Features/Sessions/Views/SessionsView.swift +++ b/scarf/scarf/Features/Sessions/Views/SessionsView.swift @@ -5,11 +5,17 @@ struct SessionsView: View { @Environment(AppCoordinator.self) private var coordinator var body: some View { - HSplitView { - sessionList - .frame(minWidth: 280, idealWidth: 320) - sessionDetail - .frame(minWidth: 400) + VStack(spacing: 0) { + if let stats = viewModel.storeStats { + statsBar(stats) + Divider() + } + HSplitView { + sessionList + .frame(minWidth: 280, idealWidth: 320) + sessionDetail + .frame(minWidth: 400) + } } .navigationTitle("Sessions") .searchable(text: $viewModel.searchText, prompt: "Search messages...") @@ -28,6 +34,33 @@ struct SessionsView: View { } } .onDisappear { Task { await viewModel.cleanup() } } + .sheet(isPresented: $viewModel.showRenameSheet) { + renameSheet + } + .confirmationDialog("Delete Session?", isPresented: $viewModel.showDeleteConfirmation) { + Button("Delete", role: .destructive) { viewModel.confirmDelete() } + Button("Cancel", role: .cancel) {} + } message: { + Text("This will permanently delete the session and all its messages.") + } + } + + private func statsBar(_ stats: SessionStoreStats) -> some View { + HStack(spacing: 16) { + Label("\(stats.totalSessions) sessions", systemImage: "bubble.left.and.bubble.right") + Label("\(stats.totalMessages) messages", systemImage: "text.bubble") + Label(stats.databaseSize, systemImage: "internaldrive") + ForEach(stats.platformCounts, id: \.platform) { item in + Label("\(item.count) \(item.platform)", systemImage: platformIcon(item.platform)) + } + Spacer() + Button("Export All") { viewModel.exportAll() } + .controlSize(.small) + } + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal) + .padding(.vertical, 6) } private var sessionList: some View { @@ -64,6 +97,12 @@ struct SessionsView: View { ForEach(viewModel.sessions) { session in SessionRow(session: session, preview: viewModel.previewFor(session)) .tag(session.id) + .contextMenu { + Button("Rename...") { viewModel.beginRename(session) } + Button("Export...") { viewModel.exportSession(session) } + Divider() + Button("Delete...", role: .destructive) { viewModel.beginDelete(session) } + } } } } @@ -73,11 +112,50 @@ struct SessionsView: View { @ViewBuilder private var sessionDetail: some View { if let session = viewModel.selectedSession { - SessionDetailView(session: session, messages: viewModel.messages, preview: viewModel.previewFor(session)) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + SessionDetailView( + session: session, + messages: viewModel.messages, + preview: viewModel.previewFor(session), + onRename: { viewModel.beginRename(session) }, + onExport: { viewModel.exportSession(session) }, + onDelete: { viewModel.beginDelete(session) } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } else { ContentUnavailableView("Select a Session", systemImage: "bubble.left.and.bubble.right", description: Text("Choose a session from the list")) .frame(maxWidth: .infinity, maxHeight: .infinity) } } + + private var renameSheet: some View { + VStack(spacing: 16) { + Text("Rename Session") + .font(.headline) + TextField("Session title", text: $viewModel.renameText) + .textFieldStyle(.roundedBorder) + .onSubmit { viewModel.confirmRename() } + HStack { + Button("Cancel") { viewModel.showRenameSheet = false } + .keyboardShortcut(.cancelAction) + Spacer() + Button("Rename") { viewModel.confirmRename() } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(viewModel.renameText.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding() + .frame(width: 400) + } + + private func platformIcon(_ platform: String) -> String { + switch platform { + case "cli": return "terminal" + case "telegram": return "paperplane" + case "discord": return "bubble.left.and.bubble.right" + case "slack": return "number" + case "email": return "envelope" + default: return "bubble.left" + } + } }