From 0344ce2b9830804790eeca444ab3cd4c8c07e25a Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Tue, 31 Mar 2026 02:53:47 -0400 Subject: [PATCH] Persist chat terminal across navigation, add session resume The terminal process now survives sidebar navigation by hoisting ChatViewModel to the app level via environment injection. The LocalProcessTerminalView instance lives in the view model rather than being recreated by NSViewRepresentable each time. Added session management: - Session menu with New Session, Continue Last, and Resume by ID - Recent sessions list pulled from the database - Hermes --resume and --continue flags for session continuity - Status indicator showing active/inactive process state - Empty state prompting user to start or resume a session Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Chat/ViewModels/ChatViewModel.swift | 79 ++++++++++++++++++- .../scarf/Features/Chat/Views/ChatView.swift | 68 ++++++++++++---- .../Chat/Views/TerminalRepresentable.swift | 67 ++++++---------- scarf/scarf/scarfApp.swift | 2 + 4 files changed, 158 insertions(+), 58 deletions(-) diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index 272671d..d9934e5 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -1,10 +1,87 @@ import Foundation +import AppKit +import SwiftTerm @Observable final class ChatViewModel { - var sessionId = UUID() + private let dataService = HermesDataService() + + var recentSessions: [HermesSession] = [] + var terminalView: LocalProcessTerminalView? + var hasActiveProcess = false + private var coordinator: Coordinator? var hermesBinaryExists: Bool { FileManager.default.fileExists(atPath: HermesPaths.hermesBinary) } + + func startNewSession() { + launchTerminal(arguments: ["chat"]) + } + + func resumeSession(_ sessionId: String) { + launchTerminal(arguments: ["chat", "--resume", sessionId]) + } + + func continueLastSession() { + launchTerminal(arguments: ["chat", "--continue"]) + } + + func loadRecentSessions() async { + let opened = await dataService.open() + guard opened else { return } + recentSessions = await dataService.fetchSessions(limit: 10) + await dataService.close() + } + + private func launchTerminal(arguments: [String]) { + if let existing = terminalView { + existing.terminate() + existing.removeFromSuperview() + } + + let terminal = LocalProcessTerminalView(frame: .zero) + terminal.font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) + terminal.nativeBackgroundColor = NSColor(red: 0.11, green: 0.12, blue: 0.14, alpha: 1.0) + terminal.nativeForegroundColor = NSColor(red: 0.85, green: 0.87, blue: 0.91, alpha: 1.0) + + let coord = Coordinator(onTerminated: { [weak self] in + self?.hasActiveProcess = false + }) + terminal.processDelegate = coord + self.coordinator = coord + + var env = ProcessInfo.processInfo.environment + env["TERM"] = "xterm-256color" + env["COLORTERM"] = "truecolor" + let envArray = env.map { "\($0.key)=\($0.value)" } + + terminal.startProcess( + executable: HermesPaths.hermesBinary, + args: arguments, + environment: envArray, + execName: nil + ) + + self.terminalView = terminal + self.hasActiveProcess = true + } + + final class Coordinator: NSObject, LocalProcessTerminalViewDelegate { + let onTerminated: () -> Void + + init(onTerminated: @escaping () -> Void) { + self.onTerminated = onTerminated + } + + func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {} + func setTerminalTitle(source: LocalProcessTerminalView, title: String) {} + func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {} + + func processTerminated(source: TerminalView, exitCode: Int32?) { + let terminal = source.getTerminal() + terminal.feed(text: "\r\n[Process exited with code \(exitCode ?? -1). Use the toolbar to start or resume a session.]\r\n") + DispatchQueue.main.async { self.onTerminated() } + } + } } diff --git a/scarf/scarf/Features/Chat/Views/ChatView.swift b/scarf/scarf/Features/Chat/Views/ChatView.swift index 21b3724..194a037 100644 --- a/scarf/scarf/Features/Chat/Views/ChatView.swift +++ b/scarf/scarf/Features/Chat/Views/ChatView.swift @@ -1,7 +1,7 @@ import SwiftUI struct ChatView: View { - @State private var viewModel = ChatViewModel() + @Environment(ChatViewModel.self) private var viewModel var body: some View { VStack(spacing: 0) { @@ -10,25 +10,62 @@ struct ChatView: View { terminalArea } .navigationTitle("Chat") + .task { await viewModel.loadRecentSessions() } } private var toolbar: some View { - HStack { + HStack(spacing: 12) { Image(systemName: "terminal") .foregroundStyle(.secondary) - Text("Hermes Terminal") - .font(.caption) - .foregroundStyle(.secondary) + + if viewModel.hasActiveProcess { + Circle() + .fill(.green) + .frame(width: 6, height: 6) + Text("Active") + .font(.caption) + .foregroundStyle(.secondary) + } else { + Circle() + .fill(.secondary) + .frame(width: 6, height: 6) + Text("No active session") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if !viewModel.hermesBinaryExists { Label("Hermes binary not found", systemImage: "exclamationmark.triangle") .font(.caption) .foregroundStyle(.red) } - Button("New Session") { - viewModel.sessionId = UUID() + + Menu { + Button("New Session") { + viewModel.startNewSession() + } + Button("Continue Last Session") { + viewModel.continueLastSession() + } + if !viewModel.recentSessions.isEmpty { + Divider() + Text("Resume Session") + ForEach(viewModel.recentSessions) { session in + Button { + viewModel.resumeSession(session.id) + } label: { + Text("\(session.displayTitle) — \(session.id.prefix(16))") + } + } + } + } label: { + Label("Session", systemImage: "play.circle") + .font(.caption) } - .controlSize(.small) + .menuStyle(.borderlessButton) + .fixedSize() } .padding(.horizontal) .padding(.vertical, 6) @@ -36,19 +73,22 @@ struct ChatView: View { @ViewBuilder private var terminalArea: some View { - if viewModel.hermesBinaryExists { - TerminalRepresentable( - command: HermesPaths.hermesBinary, - arguments: ["chat"], - environment: [:] + if let terminal = viewModel.terminalView { + PersistentTerminalView(terminalView: terminal) + } else if viewModel.hermesBinaryExists { + ContentUnavailableView( + "No Active Session", + systemImage: "terminal", + description: Text("Start a new session or resume an existing one from the Session menu above.") ) - .id(viewModel.sessionId) + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ContentUnavailableView( "Hermes Not Found", systemImage: "terminal", description: Text("Expected at \(HermesPaths.hermesBinary)") ) + .frame(maxWidth: .infinity, maxHeight: .infinity) } } } diff --git a/scarf/scarf/Features/Chat/Views/TerminalRepresentable.swift b/scarf/scarf/Features/Chat/Views/TerminalRepresentable.swift index f87c1f6..7691baf 100644 --- a/scarf/scarf/Features/Chat/Views/TerminalRepresentable.swift +++ b/scarf/scarf/Features/Chat/Views/TerminalRepresentable.swift @@ -2,52 +2,33 @@ import SwiftUI import AppKit import SwiftTerm -struct TerminalRepresentable: NSViewRepresentable { - let command: String - let arguments: [String] - let environment: [String: String] +struct PersistentTerminalView: NSViewRepresentable { + let terminalView: LocalProcessTerminalView - func makeNSView(context: Context) -> LocalProcessTerminalView { - let terminal = LocalProcessTerminalView(frame: .zero) - terminal.font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) - terminal.nativeBackgroundColor = NSColor(red: 0.11, green: 0.12, blue: 0.14, alpha: 1.0) - terminal.nativeForegroundColor = NSColor(red: 0.85, green: 0.87, blue: 0.91, alpha: 1.0) - terminal.processDelegate = context.coordinator - - var env = ProcessInfo.processInfo.environment - for (key, value) in environment { - env[key] = value - } - env["TERM"] = "xterm-256color" - env["COLORTERM"] = "truecolor" - - let envArray = env.map { "\($0.key)=\($0.value)" } - - terminal.startProcess( - executable: command, - args: arguments, - environment: envArray, - execName: nil - ) - return terminal + func makeNSView(context: Context) -> NSView { + let container = NSView() + terminalView.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(terminalView) + NSLayoutConstraint.activate([ + terminalView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + terminalView.trailingAnchor.constraint(equalTo: container.trailingAnchor), + terminalView.topAnchor.constraint(equalTo: container.topAnchor), + terminalView.bottomAnchor.constraint(equalTo: container.bottomAnchor), + ]) + return container } - func updateNSView(_ nsView: LocalProcessTerminalView, context: Context) {} - - func makeCoordinator() -> Coordinator { - Coordinator() - } - - final class Coordinator: NSObject, LocalProcessTerminalViewDelegate { - func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {} - - func setTerminalTitle(source: LocalProcessTerminalView, title: String) {} - - func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {} - - func processTerminated(source: TerminalView, exitCode: Int32?) { - let terminal = source.getTerminal() - terminal.feed(text: "\r\n[Process exited with code \(exitCode ?? -1)]\r\n") + func updateNSView(_ nsView: NSView, context: Context) { + if terminalView.superview !== nsView { + nsView.subviews.forEach { $0.removeFromSuperview() } + terminalView.translatesAutoresizingMaskIntoConstraints = false + nsView.addSubview(terminalView) + NSLayoutConstraint.activate([ + terminalView.leadingAnchor.constraint(equalTo: nsView.leadingAnchor), + terminalView.trailingAnchor.constraint(equalTo: nsView.trailingAnchor), + terminalView.topAnchor.constraint(equalTo: nsView.topAnchor), + terminalView.bottomAnchor.constraint(equalTo: nsView.bottomAnchor), + ]) } } } diff --git a/scarf/scarf/scarfApp.swift b/scarf/scarf/scarfApp.swift index ad816a3..e42bd38 100644 --- a/scarf/scarf/scarfApp.swift +++ b/scarf/scarf/scarfApp.swift @@ -5,12 +5,14 @@ struct ScarfApp: App { @State private var coordinator = AppCoordinator() @State private var fileWatcher = HermesFileWatcher() @State private var menuBarStatus = MenuBarStatus() + @State private var chatViewModel = ChatViewModel() var body: some Scene { WindowGroup { ContentView() .environment(coordinator) .environment(fileWatcher) + .environment(chatViewModel) .onAppear { fileWatcher.startWatching() menuBarStatus.startPolling()