mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 02:26: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 Foundation
|
||||||
|
import AppKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
struct SessionStoreStats {
|
||||||
|
let totalSessions: Int
|
||||||
|
let totalMessages: Int
|
||||||
|
let databaseSize: String
|
||||||
|
let platformCounts: [(platform: String, count: Int)]
|
||||||
|
}
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class SessionsViewModel {
|
final class SessionsViewModel {
|
||||||
@@ -11,12 +20,20 @@ final class SessionsViewModel {
|
|||||||
var searchText = ""
|
var searchText = ""
|
||||||
var searchResults: [HermesMessage] = []
|
var searchResults: [HermesMessage] = []
|
||||||
var isSearching = false
|
var isSearching = false
|
||||||
|
var storeStats: SessionStoreStats?
|
||||||
|
|
||||||
|
var renameSessionId: String?
|
||||||
|
var renameText = ""
|
||||||
|
var showRenameSheet = false
|
||||||
|
var showDeleteConfirmation = false
|
||||||
|
var deleteSessionId: String?
|
||||||
|
|
||||||
func load() async {
|
func load() async {
|
||||||
let opened = await dataService.open()
|
let opened = await dataService.open()
|
||||||
guard opened else { return }
|
guard opened else { return }
|
||||||
sessions = await dataService.fetchSessions(limit: 500)
|
sessions = await dataService.fetchSessions(limit: 500)
|
||||||
sessionPreviews = await dataService.fetchSessionPreviews(limit: 500)
|
sessionPreviews = await dataService.fetchSessionPreviews(limit: 500)
|
||||||
|
computeStats()
|
||||||
}
|
}
|
||||||
|
|
||||||
func previewFor(_ session: HermesSession) -> String {
|
func previewFor(_ session: HermesSession) -> String {
|
||||||
@@ -50,4 +67,127 @@ final class SessionsViewModel {
|
|||||||
func cleanup() async {
|
func cleanup() async {
|
||||||
await dataService.close()
|
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 session: HermesSession
|
||||||
let messages: [HermesMessage]
|
let messages: [HermesMessage]
|
||||||
var preview: String?
|
var preview: String?
|
||||||
|
var onRename: (() -> Void)?
|
||||||
|
var onExport: (() -> Void)?
|
||||||
|
var onDelete: (() -> Void)?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
@@ -16,22 +19,41 @@ struct SessionDetailView: View {
|
|||||||
|
|
||||||
private var sessionHeader: some View {
|
private var sessionHeader: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(preview ?? session.displayTitle)
|
HStack {
|
||||||
.font(.title3.bold())
|
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) {
|
HStack(spacing: 16) {
|
||||||
Label(session.source, systemImage: session.sourceIcon)
|
Label(session.source, systemImage: session.sourceIcon)
|
||||||
Label(session.model ?? "unknown", systemImage: "cpu")
|
Label(session.model ?? "unknown", systemImage: "cpu")
|
||||||
Label("\(session.messageCount) msgs", systemImage: "bubble.left")
|
Label("\(session.messageCount) msgs", systemImage: "bubble.left")
|
||||||
Label("\(session.toolCallCount) tools", systemImage: "wrench")
|
Label("\(session.toolCallCount) tools", systemImage: "wrench")
|
||||||
if let cost = session.estimatedCostUSD {
|
|
||||||
Label(String(format: "$%.4f", cost), systemImage: "dollarsign.circle")
|
|
||||||
}
|
|
||||||
if let date = session.startedAt {
|
if let date = session.startedAt {
|
||||||
Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar")
|
Label(date.formatted(.dateTime.month().day().hour().minute()), systemImage: "calendar")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
Text(session.id)
|
||||||
|
.font(.caption2.monospaced())
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.textSelection(.enabled)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ struct SessionsView: View {
|
|||||||
@Environment(AppCoordinator.self) private var coordinator
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HSplitView {
|
VStack(spacing: 0) {
|
||||||
sessionList
|
if let stats = viewModel.storeStats {
|
||||||
.frame(minWidth: 280, idealWidth: 320)
|
statsBar(stats)
|
||||||
sessionDetail
|
Divider()
|
||||||
.frame(minWidth: 400)
|
}
|
||||||
|
HSplitView {
|
||||||
|
sessionList
|
||||||
|
.frame(minWidth: 280, idealWidth: 320)
|
||||||
|
sessionDetail
|
||||||
|
.frame(minWidth: 400)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Sessions")
|
.navigationTitle("Sessions")
|
||||||
.searchable(text: $viewModel.searchText, prompt: "Search messages...")
|
.searchable(text: $viewModel.searchText, prompt: "Search messages...")
|
||||||
@@ -28,6 +34,33 @@ struct SessionsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDisappear { Task { await viewModel.cleanup() } }
|
.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 {
|
private var sessionList: some View {
|
||||||
@@ -64,6 +97,12 @@ struct SessionsView: View {
|
|||||||
ForEach(viewModel.sessions) { session in
|
ForEach(viewModel.sessions) { session in
|
||||||
SessionRow(session: session, preview: viewModel.previewFor(session))
|
SessionRow(session: session, preview: viewModel.previewFor(session))
|
||||||
.tag(session.id)
|
.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
|
@ViewBuilder
|
||||||
private var sessionDetail: some View {
|
private var sessionDetail: some View {
|
||||||
if let session = viewModel.selectedSession {
|
if let session = viewModel.selectedSession {
|
||||||
SessionDetailView(session: session, messages: viewModel.messages, preview: viewModel.previewFor(session))
|
SessionDetailView(
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
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 {
|
} else {
|
||||||
ContentUnavailableView("Select a Session", systemImage: "bubble.left.and.bubble.right", description: Text("Choose a session from the list"))
|
ContentUnavailableView("Select a Session", systemImage: "bubble.left.and.bubble.right", description: Text("Choose a session from the list"))
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.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