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()