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:
Alan Wizemann
2026-03-31 11:55:35 -04:00
parent 36757a8c9a
commit e4d5bb0364
3 changed files with 252 additions and 12 deletions
@@ -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"
}
}
}