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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-03-31 02:53:47 -04:00
parent eb38ee343c
commit 0344ce2b98
4 changed files with 158 additions and 58 deletions
@@ -1,10 +1,87 @@
import Foundation import Foundation
import AppKit
import SwiftTerm
@Observable @Observable
final class ChatViewModel { 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 { var hermesBinaryExists: Bool {
FileManager.default.fileExists(atPath: HermesPaths.hermesBinary) 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() }
}
}
} }
+51 -11
View File
@@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
struct ChatView: View { struct ChatView: View {
@State private var viewModel = ChatViewModel() @Environment(ChatViewModel.self) private var viewModel
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -10,25 +10,62 @@ struct ChatView: View {
terminalArea terminalArea
} }
.navigationTitle("Chat") .navigationTitle("Chat")
.task { await viewModel.loadRecentSessions() }
} }
private var toolbar: some View { private var toolbar: some View {
HStack { HStack(spacing: 12) {
Image(systemName: "terminal") Image(systemName: "terminal")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text("Hermes Terminal")
if viewModel.hasActiveProcess {
Circle()
.fill(.green)
.frame(width: 6, height: 6)
Text("Active")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else {
Circle()
.fill(.secondary)
.frame(width: 6, height: 6)
Text("No active session")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer() Spacer()
if !viewModel.hermesBinaryExists { if !viewModel.hermesBinaryExists {
Label("Hermes binary not found", systemImage: "exclamationmark.triangle") Label("Hermes binary not found", systemImage: "exclamationmark.triangle")
.font(.caption) .font(.caption)
.foregroundStyle(.red) .foregroundStyle(.red)
} }
Menu {
Button("New Session") { Button("New Session") {
viewModel.sessionId = UUID() viewModel.startNewSession()
} }
.controlSize(.small) 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)
}
.menuStyle(.borderlessButton)
.fixedSize()
} }
.padding(.horizontal) .padding(.horizontal)
.padding(.vertical, 6) .padding(.vertical, 6)
@@ -36,19 +73,22 @@ struct ChatView: View {
@ViewBuilder @ViewBuilder
private var terminalArea: some View { private var terminalArea: some View {
if viewModel.hermesBinaryExists { if let terminal = viewModel.terminalView {
TerminalRepresentable( PersistentTerminalView(terminalView: terminal)
command: HermesPaths.hermesBinary, } else if viewModel.hermesBinaryExists {
arguments: ["chat"], ContentUnavailableView(
environment: [:] "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 { } else {
ContentUnavailableView( ContentUnavailableView(
"Hermes Not Found", "Hermes Not Found",
systemImage: "terminal", systemImage: "terminal",
description: Text("Expected at \(HermesPaths.hermesBinary)") description: Text("Expected at \(HermesPaths.hermesBinary)")
) )
.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
} }
} }
@@ -2,52 +2,33 @@ import SwiftUI
import AppKit import AppKit
import SwiftTerm import SwiftTerm
struct TerminalRepresentable: NSViewRepresentable { struct PersistentTerminalView: NSViewRepresentable {
let command: String let terminalView: LocalProcessTerminalView
let arguments: [String]
let environment: [String: String]
func makeNSView(context: Context) -> LocalProcessTerminalView { func makeNSView(context: Context) -> NSView {
let terminal = LocalProcessTerminalView(frame: .zero) let container = NSView()
terminal.font = NSFont.monospacedSystemFont(ofSize: 13, weight: .regular) terminalView.translatesAutoresizingMaskIntoConstraints = false
terminal.nativeBackgroundColor = NSColor(red: 0.11, green: 0.12, blue: 0.14, alpha: 1.0) container.addSubview(terminalView)
terminal.nativeForegroundColor = NSColor(red: 0.85, green: 0.87, blue: 0.91, alpha: 1.0) NSLayoutConstraint.activate([
terminal.processDelegate = context.coordinator terminalView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
terminalView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
var env = ProcessInfo.processInfo.environment terminalView.topAnchor.constraint(equalTo: container.topAnchor),
for (key, value) in environment { terminalView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
env[key] = value ])
} return container
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 updateNSView(_ nsView: LocalProcessTerminalView, context: Context) {} func updateNSView(_ nsView: NSView, context: Context) {
if terminalView.superview !== nsView {
func makeCoordinator() -> Coordinator { nsView.subviews.forEach { $0.removeFromSuperview() }
Coordinator() terminalView.translatesAutoresizingMaskIntoConstraints = false
} nsView.addSubview(terminalView)
NSLayoutConstraint.activate([
final class Coordinator: NSObject, LocalProcessTerminalViewDelegate { terminalView.leadingAnchor.constraint(equalTo: nsView.leadingAnchor),
func sizeChanged(source: LocalProcessTerminalView, newCols: Int, newRows: Int) {} terminalView.trailingAnchor.constraint(equalTo: nsView.trailingAnchor),
terminalView.topAnchor.constraint(equalTo: nsView.topAnchor),
func setTerminalTitle(source: LocalProcessTerminalView, title: String) {} terminalView.bottomAnchor.constraint(equalTo: nsView.bottomAnchor),
])
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")
} }
} }
} }
+2
View File
@@ -5,12 +5,14 @@ struct ScarfApp: App {
@State private var coordinator = AppCoordinator() @State private var coordinator = AppCoordinator()
@State private var fileWatcher = HermesFileWatcher() @State private var fileWatcher = HermesFileWatcher()
@State private var menuBarStatus = MenuBarStatus() @State private var menuBarStatus = MenuBarStatus()
@State private var chatViewModel = ChatViewModel()
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() ContentView()
.environment(coordinator) .environment(coordinator)
.environment(fileWatcher) .environment(fileWatcher)
.environment(chatViewModel)
.onAppear { .onAppear {
fileWatcher.startWatching() fileWatcher.startWatching()
menuBarStatus.startPolling() menuBarStatus.startPolling()