mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user