mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user