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 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() }
}
}
}
+51 -11
View File
@@ -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")
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)
}
Menu {
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(.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)
}
}
}
@@ -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),
])
}
}
}
+2
View File
@@ -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()