mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 02:26:37 +00:00
M9 #4.2: project-scoped chat + shared SFTP parity services
ScarfGo now supports the Mac app's project-chat flow end-to-end.
Tapping + in Chat opens a sheet with two options:
1. Quick chat — cwd = $HOME (previous default).
2. In project… — pick from the remote Hermes's project registry,
spawn hermes acp with cwd = project.path, record the attribution.
Shared infrastructure for the SFTP parity (so Mac + ScarfGo use the
exact same record types + persistence logic):
- SessionProjectMap — moved from scarf/scarf/Core/Models/ to
ScarfCore. Public struct. Mac consumer unchanged (imports it via
ScarfCore now).
- SessionAttributionService — moved from Mac target to ScarfCore.
Was already transport-backed, so the port is straight lift-and-
shift: made public, added #if canImport(os) guards around the
Logger imports for Linux CI. Mac ChatViewModel and ProjectSessions
VM still call it the same way.
- ProjectContextBlock — new ScarfCore-level primitive that owns the
marker-splice logic for the Scarf-managed region of AGENTS.md:
- applyBlock(_:to:) — pure text splice with 3-case handling.
- writeBlock(_:forProjectAt:context:) — transport-backed write.
- renderMinimalBlock(projectName:projectPath:) — iOS-side block
composer (no template-manifest or cron-attribution fields — iOS
doesn't yet surface those concepts; markers + identity headers
match Mac output byte-for-byte so a project scaffolded on iOS
round-trips cleanly through the Mac).
Mac's ProjectAgentContextService stays in place — still the richer
block renderer (template manifest + cron jobs) — but it now forwards
beginMarker/endMarker/applyBlock to ProjectContextBlock so both
platforms share invariant strings and splice logic. Duplicate
implementations were a recipe for drift.
ScarfGo side:
- Chat/ProjectPickerSheet.swift — two-section sheet (Quick chat /
In project…). Loads the project list over SFTP via
ProjectDashboardService (already transport-backed, works on iOS).
Archived projects hidden (matching Mac sidebar behaviour).
- ChatController.resetAndStartInProject(_:) — stops the current
session, writes the minimal context block to <project>/AGENTS.md
over SFTP, spawns hermes acp with cwd = project.path, records the
attribution via SessionAttributionService. Non-fatal on block-
write failure (chat still starts).
- ChatController.startInternal(...) — refactored to take an optional
projectPath + projectName, so the regular start() and the new
project path share one ACP setup path. Attribution write happens
after newSession returns and the sessionId is known.
Project chip in the chat nav bar is deferred — on-the-go users know
they just picked a project in the sheet, the chip is polish we can
add post-TestFlight. Both schemes build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+8
-15
@@ -14,21 +14,14 @@ import Foundation
|
||||
/// compatible: if Hermes ever gains a canonical `cwd` column, Scarf
|
||||
/// can prefer that and fall back to this file for pre-upgrade
|
||||
/// sessions. Missing file → empty map (nothing attributed yet).
|
||||
struct SessionProjectMap: Codable, Sendable {
|
||||
/// session-id → absolute-project-path. Both strings are opaque
|
||||
/// from this file's perspective; the service validates project
|
||||
/// paths against the live registry when building the reverse
|
||||
/// lookup used by the Sessions tab, so stale entries for
|
||||
/// removed projects are ignored at read time without needing a
|
||||
/// write-side cleanup.
|
||||
var mappings: [String: String]
|
||||
///
|
||||
/// Promoted to ScarfCore in M9 #4.2 so iOS can use the same record
|
||||
/// type — ScarfGo's project-scoped chat writes here over SFTP.
|
||||
public struct SessionProjectMap: Codable, Sendable {
|
||||
public var mappings: [String: String]
|
||||
public var updatedAt: String?
|
||||
|
||||
/// ISO-8601 timestamp of the most recent write. Informational
|
||||
/// only — not used for any decision logic. Useful when debugging
|
||||
/// a stale sidecar ("when was this last updated?").
|
||||
var updatedAt: String?
|
||||
|
||||
init(mappings: [String: String] = [:], updatedAt: String? = nil) {
|
||||
public init(mappings: [String: String] = [:], updatedAt: String? = nil) {
|
||||
self.mappings = mappings
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
@@ -37,7 +30,7 @@ struct SessionProjectMap: Codable, Sendable {
|
||||
/// `updatedAt` field. Matches the format used elsewhere in
|
||||
/// Scarf (e.g. `TemplateLock.installedAt`) so tooling that
|
||||
/// greps across .json files sees consistent timestamps.
|
||||
static func nowISO8601() -> String {
|
||||
public static func nowISO8601() -> String {
|
||||
ISO8601DateFormatter().string(from: Date())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import Foundation
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
/// Pure block-splice logic for the Scarf-managed region of a project's
|
||||
/// `<project>/AGENTS.md`. Shared by Mac (which wraps it in
|
||||
/// `ProjectAgentContextService` with template-manifest + cron-aware
|
||||
/// block rendering) and ScarfGo (which renders a simpler block and
|
||||
/// writes it over SFTP).
|
||||
///
|
||||
/// The marker contract is a cross-platform invariant — both apps must
|
||||
/// produce byte-identical markers so a Mac-scaffolded block round-trips
|
||||
/// through iOS and vice-versa without either side treating the other's
|
||||
/// content as "missing markers."
|
||||
public enum ProjectContextBlock {
|
||||
|
||||
/// Load-bearing across releases. Do not change these strings
|
||||
/// without a coordinated migration — existing project AGENTS.md
|
||||
/// files on disk carry them.
|
||||
public static let beginMarker = "<!-- scarf-project:begin -->"
|
||||
public static let endMarker = "<!-- scarf-project:end -->"
|
||||
|
||||
/// Errors surfaced by writers. Narrow set — most callers just log
|
||||
/// and continue; a missing project-context block is a polish
|
||||
/// degradation, not a chat-start blocker.
|
||||
public enum WriteError: Error, LocalizedError {
|
||||
case encodingFailed
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .encodingFailed: return "Couldn't encode AGENTS.md block as UTF-8"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Splice `block` into `existing`, preserving everything outside
|
||||
/// the markers. Three cases:
|
||||
/// 1. `existing` has both markers → replace inclusive region.
|
||||
/// 2. `existing` has no markers → prepend block + blank line.
|
||||
/// 3. `existing` has only a begin marker → prepend (don't guess).
|
||||
public static func applyBlock(_ block: String, to existing: String) -> String {
|
||||
guard let beginRange = existing.range(of: beginMarker),
|
||||
let endRange = existing.range(
|
||||
of: endMarker,
|
||||
range: beginRange.upperBound..<existing.endIndex
|
||||
)
|
||||
else {
|
||||
let trimmed = existing.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return block + "\n" }
|
||||
return block + "\n\n" + existing
|
||||
}
|
||||
var upperBound = endRange.upperBound
|
||||
while upperBound < existing.endIndex,
|
||||
existing[upperBound].isNewline {
|
||||
upperBound = existing.index(after: upperBound)
|
||||
}
|
||||
let before = String(existing[existing.startIndex..<beginRange.lowerBound])
|
||||
let after = String(existing[upperBound..<existing.endIndex])
|
||||
let prefix = before.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? ""
|
||||
: trimmingRightNewlines(before) + "\n\n"
|
||||
let suffix = after.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? "\n"
|
||||
: "\n\n" + trimmingLeftNewlines(after)
|
||||
return prefix + block + suffix
|
||||
}
|
||||
|
||||
/// Read `<project>/AGENTS.md`, splice in the given block, write
|
||||
/// back — all via the provided context's transport. Idempotent on
|
||||
/// identical inputs.
|
||||
///
|
||||
/// Called by ScarfGo's ChatController.startNewSession when the
|
||||
/// user picks "In project…". Mac's ProjectAgentContextService is
|
||||
/// a richer wrapper that constructs the block first, but the
|
||||
/// persistence step uses the same splice logic under the hood.
|
||||
public static func writeBlock(
|
||||
_ block: String,
|
||||
forProjectAt projectPath: String,
|
||||
context: ServerContext
|
||||
) throws {
|
||||
let transport = context.makeTransport()
|
||||
let agentsMdPath = projectPath + "/AGENTS.md"
|
||||
|
||||
if !transport.fileExists(projectPath) {
|
||||
try transport.createDirectory(projectPath)
|
||||
}
|
||||
|
||||
if !transport.fileExists(agentsMdPath) {
|
||||
let data = (block + "\n").data(using: .utf8) ?? Data()
|
||||
try transport.writeFile(agentsMdPath, data: data)
|
||||
return
|
||||
}
|
||||
|
||||
let existingData = try transport.readFile(agentsMdPath)
|
||||
let existing = String(data: existingData, encoding: .utf8) ?? ""
|
||||
let rewritten = applyBlock(block, to: existing)
|
||||
guard let outData = rewritten.data(using: .utf8) else {
|
||||
throw WriteError.encodingFailed
|
||||
}
|
||||
guard outData != existingData else { return }
|
||||
try transport.writeFile(agentsMdPath, data: outData)
|
||||
}
|
||||
|
||||
/// Render a minimal Scarf-managed block for iOS ScarfGo usage.
|
||||
/// Omits the template-manifest + cron-job sections that the Mac
|
||||
/// service fills in — ScarfGo v1 doesn't surface those concepts
|
||||
/// yet. The marker + identity headers match the Mac output byte-
|
||||
/// for-byte where the content overlaps, so a project scaffolded
|
||||
/// on iOS round-trips cleanly through the Mac.
|
||||
public static func renderMinimalBlock(projectName: String, projectPath: String) -> String {
|
||||
var lines: [String] = []
|
||||
lines.append(beginMarker)
|
||||
lines.append("## Scarf project context")
|
||||
lines.append("")
|
||||
lines.append("_Auto-generated by Scarf — do not edit between the begin/end markers._")
|
||||
lines.append("")
|
||||
lines.append("You are operating inside a Scarf project named **\"\(projectName)\"**. This chat session's working directory is the project's directory — path-relative tool calls resolve inside the project.")
|
||||
lines.append("")
|
||||
lines.append("- **Project directory:** `\(projectPath)`")
|
||||
lines.append("- **Dashboard:** `\(projectPath)/.scarf/dashboard.json`")
|
||||
lines.append("")
|
||||
lines.append("Any content below this block is template- or user-authored; preserve and defer to it for project-specific behavior. Do NOT modify content inside these markers — Scarf rewrites this block on every project-scoped chat start.")
|
||||
lines.append(endMarker)
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private static func trimmingRightNewlines(_ s: String) -> String {
|
||||
var result = s
|
||||
while let last = result.last, last.isNewline {
|
||||
result.removeLast()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func trimmingLeftNewlines(_ s: String) -> String {
|
||||
var result = s
|
||||
while let first = result.first, first.isNewline {
|
||||
result.removeFirst()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
+26
-32
@@ -1,12 +1,12 @@
|
||||
import Foundation
|
||||
#if canImport(os)
|
||||
import os
|
||||
import ScarfCore
|
||||
#endif
|
||||
|
||||
/// Owns the sidecar that attributes Hermes session IDs to Scarf
|
||||
/// project paths. The `cwd` passed to `hermes acp` at session
|
||||
/// creation is ephemeral from Hermes's perspective (not written to
|
||||
/// `state.db`), so Scarf keeps this Scarf-owned record parallel to
|
||||
/// Hermes's session store.
|
||||
/// project paths. Promoted to ScarfCore in M9 #4.2 so ScarfGo can
|
||||
/// write project attributions over SFTP — the whole service is
|
||||
/// transport-based, so Mac and iOS share the same code path.
|
||||
///
|
||||
/// File: `~/.hermes/scarf/session_project_map.json` (resolved via
|
||||
/// `HermesPathSet.sessionProjectMap`).
|
||||
@@ -16,14 +16,15 @@ import ScarfCore
|
||||
/// disk. Concurrent writers (two Scarf windows on the same
|
||||
/// `~/.hermes`) are safe at the file level — last write wins —
|
||||
/// but the in-memory read in one window may lag until that window
|
||||
/// reloads. Acceptable for v2.3's scale; revisit if multi-window
|
||||
/// cross-talk becomes a problem.
|
||||
struct SessionAttributionService: Sendable {
|
||||
/// reloads.
|
||||
public struct SessionAttributionService: Sendable {
|
||||
#if canImport(os)
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "SessionAttributionService")
|
||||
#endif
|
||||
|
||||
let context: ServerContext
|
||||
public let context: ServerContext
|
||||
|
||||
nonisolated init(context: ServerContext = .local) {
|
||||
public nonisolated init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
@@ -32,7 +33,7 @@ struct SessionAttributionService: Sendable {
|
||||
/// Load the current sidecar contents. Missing file or unparseable
|
||||
/// JSON returns an empty map — the sidecar is a convenience
|
||||
/// index, not a source of truth for anything load-bearing.
|
||||
nonisolated func load() -> SessionProjectMap {
|
||||
public nonisolated func load() -> SessionProjectMap {
|
||||
let path = context.paths.sessionProjectMap
|
||||
let transport = context.makeTransport()
|
||||
guard transport.fileExists(path) else {
|
||||
@@ -42,26 +43,22 @@ struct SessionAttributionService: Sendable {
|
||||
let data = try transport.readFile(path)
|
||||
return try JSONDecoder().decode(SessionProjectMap.self, from: data)
|
||||
} catch {
|
||||
#if canImport(os)
|
||||
Self.logger.warning("session-project-map parse failed at \(path, privacy: .public): \(error.localizedDescription, privacy: .public); returning empty map")
|
||||
#endif
|
||||
return SessionProjectMap()
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up the project path a given session was attributed to.
|
||||
/// Returns nil for unattributed sessions (CLI-started, or
|
||||
/// started before v2.3) — those surface in the global Sessions
|
||||
/// sidebar unchanged and don't appear in any project's Sessions
|
||||
/// tab.
|
||||
nonisolated func projectPath(for sessionID: String) -> String? {
|
||||
/// Returns nil for unattributed sessions.
|
||||
public nonisolated func projectPath(for sessionID: String) -> String? {
|
||||
load().mappings[sessionID]
|
||||
}
|
||||
|
||||
/// Reverse lookup: every session ID attributed to the given
|
||||
/// project path. Used by the per-project Sessions tab to filter
|
||||
/// the global session list. Comparison is exact-string; the
|
||||
/// registry stores absolute paths and we write absolute paths,
|
||||
/// so no normalisation is needed in practice.
|
||||
nonisolated func sessionIDs(forProject projectPath: String) -> Set<String> {
|
||||
/// project path.
|
||||
public nonisolated func sessionIDs(forProject projectPath: String) -> Set<String> {
|
||||
let map = load()
|
||||
return Set(map.mappings.filter { $0.value == projectPath }.keys)
|
||||
}
|
||||
@@ -69,11 +66,8 @@ struct SessionAttributionService: Sendable {
|
||||
// MARK: - Write
|
||||
|
||||
/// Record that `sessionID` was created under the given project
|
||||
/// path. Idempotent — repeated calls for the same pair are no-
|
||||
/// ops. Replacing an existing mapping (session moved to a
|
||||
/// different project) is legal but expected to be rare; the
|
||||
/// caller decides when that's correct.
|
||||
nonisolated func attribute(sessionID: String, toProjectPath projectPath: String) {
|
||||
/// path. Idempotent.
|
||||
public nonisolated func attribute(sessionID: String, toProjectPath projectPath: String) {
|
||||
var map = load()
|
||||
if map.mappings[sessionID] == projectPath {
|
||||
return
|
||||
@@ -83,12 +77,10 @@ struct SessionAttributionService: Sendable {
|
||||
persist(map)
|
||||
}
|
||||
|
||||
/// Remove a mapping. Called in v2.3's Sessions-tab code path is
|
||||
/// minimal — we don't currently prune on session delete because
|
||||
/// Hermes owns session lifecycle and we don't observe deletes.
|
||||
/// Exposed for future roadmap items (e.g. explicit "detach
|
||||
/// from project" action) and tests.
|
||||
nonisolated func forget(sessionID: String) {
|
||||
/// Remove a mapping. Exposed for future "detach from project"
|
||||
/// UIs and tests; today's Mac + iOS call sites don't invoke it
|
||||
/// because Hermes owns session lifecycle.
|
||||
public nonisolated func forget(sessionID: String) {
|
||||
var map = load()
|
||||
guard map.mappings.removeValue(forKey: sessionID) != nil else { return }
|
||||
map.updatedAt = SessionProjectMap.nowISO8601()
|
||||
@@ -110,7 +102,9 @@ struct SessionAttributionService: Sendable {
|
||||
let data = try encoder.encode(map)
|
||||
try transport.writeFile(path, data: data)
|
||||
} catch {
|
||||
#if canImport(os)
|
||||
Self.logger.error("failed to persist session-project-map at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ struct ChatView: View {
|
||||
@Environment(\.scarfGoCoordinator) private var coordinator
|
||||
@Environment(\.serverContext) private var envContext
|
||||
@State private var controller: ChatController
|
||||
@State private var showProjectPicker = false
|
||||
|
||||
init(config: IOSServerConfig, key: SSHKeyBundle) {
|
||||
self.config = config
|
||||
@@ -50,13 +51,24 @@ struct ChatView: View {
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
Task { await controller.resetAndStartNewSession() }
|
||||
showProjectPicker = true
|
||||
} label: {
|
||||
Image(systemName: "plus.bubble")
|
||||
}
|
||||
.disabled(controller.state == .connecting)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showProjectPicker) {
|
||||
ProjectPickerSheet(
|
||||
context: config.toServerContext(id: Self.sharedContextID),
|
||||
onQuickChat: {
|
||||
Task { await controller.resetAndStartNewSession() }
|
||||
},
|
||||
onProject: { project in
|
||||
Task { await controller.resetAndStartInProject(project) }
|
||||
}
|
||||
)
|
||||
}
|
||||
.task {
|
||||
// Dashboard row taps set `pendingResumeSessionID` on the
|
||||
// coordinator before switching to the Chat tab. Honor
|
||||
@@ -453,6 +465,114 @@ final class ChatController {
|
||||
await start()
|
||||
}
|
||||
|
||||
/// User tapped "In project… <project>". Stop, reset, and start
|
||||
/// with the project's path as cwd. Writes the Scarf-managed
|
||||
/// AGENTS.md block via ProjectContextBlock BEFORE spawning `hermes
|
||||
/// acp`, so Hermes sees the project context at boot. Records the
|
||||
/// returned session id in the attribution sidecar.
|
||||
func resetAndStartInProject(_ project: ProjectEntry) async {
|
||||
await stop()
|
||||
vm.reset()
|
||||
// Write the context block first. Non-fatal on failure — chat
|
||||
// still starts, just without the managed block; the user sees
|
||||
// the error via controller.state if it escalates.
|
||||
let block = ProjectContextBlock.renderMinimalBlock(
|
||||
projectName: project.name,
|
||||
projectPath: project.path
|
||||
)
|
||||
let ctx = context
|
||||
await Task.detached {
|
||||
try? ProjectContextBlock.writeBlock(
|
||||
block,
|
||||
forProjectAt: project.path,
|
||||
context: ctx
|
||||
)
|
||||
}.value
|
||||
await start(projectPath: project.path, projectName: project.name)
|
||||
}
|
||||
|
||||
/// Inline variant of `start()` that accepts a cwd + attribution
|
||||
/// hooks. The default `start()` delegates to this with nil project
|
||||
/// fields, so the ACP code path stays single-sourced.
|
||||
private func startInternal(
|
||||
projectPath: String?,
|
||||
projectName: String?
|
||||
) async {
|
||||
if state == .connecting || state == .ready { return }
|
||||
state = .connecting
|
||||
let client = ACPClient.forIOSApp(
|
||||
context: context,
|
||||
keyProvider: {
|
||||
let store = KeychainSSHKeyStore()
|
||||
guard let key = try await store.load() else {
|
||||
throw SSHKeyStoreError.backendFailure(
|
||||
message: "No SSH key in Keychain — re-run onboarding.",
|
||||
osStatus: nil
|
||||
)
|
||||
}
|
||||
return key
|
||||
}
|
||||
)
|
||||
self.client = client
|
||||
vm.acpStderrProvider = { [weak client] in
|
||||
await client?.recentStderr ?? ""
|
||||
}
|
||||
|
||||
do {
|
||||
try await client.start()
|
||||
} catch {
|
||||
state = .failed(error.localizedDescription)
|
||||
await vm.recordACPFailure(error, client: client)
|
||||
return
|
||||
}
|
||||
|
||||
let stream = await client.events
|
||||
eventTask = Task { [weak self] in
|
||||
for await event in stream {
|
||||
guard let self else { break }
|
||||
await MainActor.run {
|
||||
self.vm.handleACPEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
// Use the project's path as cwd when provided; else the
|
||||
// remote user's home, matching the pre-M9 default.
|
||||
let cwd: String
|
||||
if let projectPath {
|
||||
cwd = projectPath
|
||||
} else {
|
||||
cwd = await context.resolvedUserHome()
|
||||
}
|
||||
let sessionId = try await client.newSession(cwd: cwd)
|
||||
vm.setSessionId(sessionId)
|
||||
state = .ready
|
||||
|
||||
// If this was a project-scoped session, record the
|
||||
// attribution so the Mac's per-project Sessions tab picks
|
||||
// it up. Best-effort — ACP session creation already won,
|
||||
// a failed attribution write is cosmetic.
|
||||
if let projectPath {
|
||||
let ctx = context
|
||||
Task.detached {
|
||||
SessionAttributionService(context: ctx)
|
||||
.attribute(sessionID: sessionId, toProjectPath: projectPath)
|
||||
}
|
||||
}
|
||||
_ = projectName // reserved for future chat-header chip
|
||||
} catch {
|
||||
state = .failed(error.localizedDescription)
|
||||
await vm.recordACPFailure(error, client: client)
|
||||
await stop()
|
||||
}
|
||||
}
|
||||
|
||||
/// Public entry used internally by resetAndStartInProject.
|
||||
func start(projectPath: String, projectName: String) async {
|
||||
await startInternal(projectPath: projectPath, projectName: projectName)
|
||||
}
|
||||
|
||||
/// Resume an existing ACP session. Called from ChatView when the
|
||||
/// coordinator carries a `pendingResumeSessionID` (Dashboard row
|
||||
/// tap). If we're currently on a different session, stop first
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Sheet presented from ChatView's "+" toolbar button. Offers two
|
||||
/// modes:
|
||||
/// - **Quick chat** — starts with `cwd = $HOME`, no project attribution.
|
||||
/// The current default behavior.
|
||||
/// - **In project…** — lets the user pick a registered project. On
|
||||
/// confirm, the caller is handed back the project path so it can
|
||||
/// (1) write the Scarf-managed AGENTS.md block via
|
||||
/// `ProjectContextBlock.writeBlock` and (2) spawn `hermes acp` with
|
||||
/// `cwd = project.path`, then attribute the resulting session.
|
||||
///
|
||||
/// The project list is loaded from the remote Hermes's
|
||||
/// `~/.hermes/scarf/projects.json` via the shared
|
||||
/// `ProjectDashboardService` (transport-backed, so SFTP works).
|
||||
struct ProjectPickerSheet: View {
|
||||
let context: ServerContext
|
||||
let onQuickChat: () -> Void
|
||||
let onProject: (ProjectEntry) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var projects: [ProjectEntry] = []
|
||||
@State private var isLoading: Bool = true
|
||||
@State private var loadError: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Section {
|
||||
Button {
|
||||
onQuickChat()
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Image(systemName: "bolt.horizontal.circle.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.tint)
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Quick chat")
|
||||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.primary)
|
||||
Text("No project — agent runs in your home directory.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.scarfGoCompactListRow()
|
||||
}
|
||||
|
||||
Section("In project…") {
|
||||
if isLoading {
|
||||
HStack { Spacer(); ProgressView(); Spacer() }
|
||||
.padding(.vertical, 8)
|
||||
} else if let err = loadError {
|
||||
Label(err, systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.font(.caption)
|
||||
} else if projects.isEmpty {
|
||||
Text("No Scarf projects registered yet. Create one in the Mac app's Projects sidebar.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(sortedVisibleProjects) { project in
|
||||
Button {
|
||||
onProject(project)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Image(systemName: "folder.fill")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.tint)
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(project.name)
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
Text(project.path)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.scarfGoCompactListRow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scarfGoListDensity()
|
||||
.navigationTitle("New chat")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
.task { await loadProjects() }
|
||||
}
|
||||
.presentationDetents([.height(320), .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
/// Hide archived projects from the picker (they're deliberately
|
||||
/// out-of-sight on the Mac sidebar; honor that on iOS too).
|
||||
/// Sort alphabetically for predictability.
|
||||
private var sortedVisibleProjects: [ProjectEntry] {
|
||||
projects
|
||||
.filter { !$0.archived }
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
/// Load the project registry over SFTP via the shared
|
||||
/// `ProjectDashboardService`. Transport-backed, so on iOS this
|
||||
/// reads `~/.hermes/scarf/projects.json` through the open Citadel
|
||||
/// connection. A missing/empty registry isn't an error — just
|
||||
/// means no projects are configured yet.
|
||||
private func loadProjects() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
let ctx = context
|
||||
let loaded: [ProjectEntry] = await Task.detached {
|
||||
let service = ProjectDashboardService(context: ctx)
|
||||
return service.loadRegistry().projects
|
||||
}.value
|
||||
projects = loaded
|
||||
}
|
||||
}
|
||||
@@ -41,11 +41,10 @@ import ScarfCore
|
||||
struct ProjectAgentContextService: Sendable {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectAgentContextService")
|
||||
|
||||
/// Marker strings. Load-bearing: the format must stay stable
|
||||
/// across releases so existing project AGENTS.md files continue
|
||||
/// to be recognized and rewritten cleanly.
|
||||
static let beginMarker = "<!-- scarf-project:begin -->"
|
||||
static let endMarker = "<!-- scarf-project:end -->"
|
||||
/// Marker strings. Delegated to ScarfCore's `ProjectContextBlock`
|
||||
/// in M9 #4.2 so both Mac and ScarfGo use identical markers.
|
||||
static let beginMarker = ProjectContextBlock.beginMarker
|
||||
static let endMarker = ProjectContextBlock.endMarker
|
||||
|
||||
let context: ServerContext
|
||||
|
||||
@@ -115,44 +114,10 @@ struct ProjectAgentContextService: Sendable {
|
||||
/// truncate to EOF (as the memory-block installer does)
|
||||
/// because an orphaned begin on this file is more likely
|
||||
/// hand-typed than a corrupt Scarf write.
|
||||
/// Kept as a thin forwarder so pre-existing callers + tests keep
|
||||
/// working. The logic lives in ScarfCore now (M9 #4.2).
|
||||
nonisolated static func applyBlock(block: String, to existing: String) -> String {
|
||||
guard let beginRange = existing.range(of: beginMarker),
|
||||
let endRange = existing.range(
|
||||
of: endMarker,
|
||||
range: beginRange.upperBound..<existing.endIndex
|
||||
)
|
||||
else {
|
||||
// No well-formed Scarf block present — prepend.
|
||||
let trimmedExisting = existing.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedExisting.isEmpty {
|
||||
return block + "\n"
|
||||
}
|
||||
return block + "\n\n" + existing
|
||||
}
|
||||
// Full span: from the begin marker through the end marker
|
||||
// (inclusive). Consumes any trailing whitespace/newlines
|
||||
// immediately following the end marker so a re-render of a
|
||||
// shorter block doesn't leave a dangling blank line.
|
||||
var upperBound = endRange.upperBound
|
||||
while upperBound < existing.endIndex,
|
||||
existing[upperBound].isNewline {
|
||||
upperBound = existing.index(after: upperBound)
|
||||
}
|
||||
let before = String(existing[existing.startIndex..<beginRange.lowerBound])
|
||||
let after = String(existing[upperBound..<existing.endIndex])
|
||||
// Preserve the leading whitespace / content structure of
|
||||
// `before` but ensure exactly one blank line separates it
|
||||
// from the new block when there IS prior content.
|
||||
let prefix = before.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? ""
|
||||
: before.trimmingRightNewlines() + "\n\n"
|
||||
// Suffix: a blank line BEFORE the remaining content, ensuring
|
||||
// the template/user content is visually separated from the
|
||||
// Scarf block.
|
||||
let suffix = after.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? "\n"
|
||||
: "\n\n" + after.trimmingLeftNewlines()
|
||||
return prefix + block + suffix
|
||||
ProjectContextBlock.applyBlock(block, to: existing)
|
||||
}
|
||||
|
||||
// MARK: - Block rendering
|
||||
|
||||
Reference in New Issue
Block a user