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 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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user