diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMessage.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMessage.swift index a4eab3d..233bd8e 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMessage.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesMessage.swift @@ -72,6 +72,20 @@ public struct HermesToolCall: Identifiable, Sendable, Codable { public let functionName: String public let arguments: String + /// Wall-clock duration of the tool call. Set on ACP `toolCallComplete` + /// (or equivalent) by `RichChatViewModel`. Nil for sessions loaded + /// from `state.db` (no live timing) and for in-flight calls. + public var duration: TimeInterval? + + /// Process exit code, when the tool kind is `.execute` and the + /// tool-result message exposes one. Best-effort parse of the result + /// content; nil when not applicable / not parseable. + public var exitCode: Int? + + /// Wall-clock timestamp the call was emitted by Hermes. Set on ACP + /// `toolCallStart`. Nil for sessions loaded from `state.db`. + public var startedAt: Date? + public enum CodingKeys: String, CodingKey { case callId = "id" case type @@ -83,10 +97,20 @@ public struct HermesToolCall: Identifiable, Sendable, Codable { case arguments } - public init(callId: String, functionName: String, arguments: String) { + public init( + callId: String, + functionName: String, + arguments: String, + duration: TimeInterval? = nil, + exitCode: Int? = nil, + startedAt: Date? = nil + ) { self.callId = callId self.functionName = functionName self.arguments = arguments + self.duration = duration + self.exitCode = exitCode + self.startedAt = startedAt } public init(from decoder: Decoder) throws { @@ -95,6 +119,11 @@ public struct HermesToolCall: Identifiable, Sendable, Codable { let funcContainer = try container.nestedContainer(keyedBy: FunctionKeys.self, forKey: .function) functionName = try funcContainer.decode(String.self, forKey: .name) arguments = try funcContainer.decode(String.self, forKey: .arguments) + // Telemetry fields are populated locally from ACP events, never + // persisted via Codable, so they decode as nil. + duration = nil + exitCode = nil + startedAt = nil } public func encode(to encoder: Encoder) throws { diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift index 84e2abe..34ab813 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift @@ -37,7 +37,11 @@ public final class RichChatViewModel { public init(context: ServerContext = .local) { self.context = context self.dataService = HermesDataService(context: context) - loadQuickCommands() + // Quick-commands load happens in `reset()`, which every chat-start + // path calls before the user can interact (iOS: ChatController.start; + // Mac: ChatViewModel.startNewSession/resumeSession/continueLastSession). + // Calling it here too caused two parallel SFTP reads of config.yaml + // on iOS chat startup. } @@ -591,13 +595,29 @@ public final class RichChatViewModel { let toolCall = HermesToolCall( callId: call.toolCallId, functionName: call.functionName, - arguments: call.argumentsJSON + arguments: call.argumentsJSON, + startedAt: Date() ) streamingToolCalls.append(toolCall) upsertStreamingMessage() } private func handleToolCallComplete(_ update: ACPToolCallUpdateEvent) { + // Populate live telemetry on the matching streaming call BEFORE + // finalizing — once finalize runs, streamingToolCalls is cleared + // and the call is locked into the parent HermesMessage's `let + // toolCalls`. Mutating here lets `finalizeStreamingMessage()` + // promote a HermesToolCall that already carries duration + + // exitCode for the inspector to render. No-op for sessions + // loaded from `state.db` (no live event ever fires). + if let idx = streamingToolCalls.firstIndex(where: { $0.callId == update.toolCallId }) { + let started = streamingToolCalls[idx].startedAt + if let started { + streamingToolCalls[idx].duration = Date().timeIntervalSince(started) + } + streamingToolCalls[idx].exitCode = Self.exitCode(forStatus: update.status) + } + // Finalize the streaming assistant message (with its tool calls) as a permanent message finalizeStreamingMessage() @@ -620,6 +640,19 @@ public final class RichChatViewModel { buildMessageGroups() } + /// Derive a synthetic exit code from the ACP update event's status + /// string. Hermes reports `completed`/`error`/`failed`/`canceled`; + /// we collapse to 0 for success, 1 for known-failure variants, nil + /// for anything else (so the inspector renders "—" rather than + /// fabricating a value). + private static func exitCode(forStatus status: String) -> Int? { + switch status.lowercased() { + case "completed", "success", "ok": return 0 + case "error", "failed", "canceled", "cancelled": return 1 + default: return nil + } + } + private func handlePromptComplete(response: ACPPromptResult) { // Detect a failed prompt that produced no assistant output — e.g. // Hermes returning `stopReason: "refusal"` when the session was diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index 955c982..f174538 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -33,6 +33,16 @@ final class ChatViewModel { var recentSessions: [HermesSession] = [] var sessionPreviews: [String: String] = [:] + + /// Per-recent-session project attribution. Keyed by `HermesSession.id`, + /// value is the project's display name. Populated alongside + /// `recentSessions` via a single batched read in `loadRecentSessions()`. + /// Sessions with no entry are unattributed (global / quick chats). + private(set) var sessionProjectNames: [String: String] = [:] + + /// All registered projects, used to build the project filter menu in + /// the chat session list pane. Loaded alongside `sessionProjectNames`. + private(set) var allProjects: [ProjectEntry] = [] var terminalView: LocalProcessTerminalView? var hasActiveProcess = false var voiceEnabled = false @@ -42,6 +52,31 @@ final class ChatViewModel { let richChatViewModel: RichChatViewModel private var coordinator: Coordinator? + /// `callId` of the tool call currently surfaced in the chat + /// inspector pane, or nil when nothing is focused. Set by + /// `ToolCallCard` taps in the transcript; cleared by the inspector's + /// xmark close. Mac-only state — the inspector is a Mac-target view, + /// so this lives on the Mac `ChatViewModel` rather than the + /// cross-platform `RichChatViewModel`. + var focusedToolCallId: String? + + /// Resolved focus target for the inspector. Walks + /// `richChatViewModel.messageGroups` to find the matching + /// `HermesToolCall` and its tool-result message (when present). + /// Returns nil when nothing is focused or the focused id no longer + /// resolves (e.g., session reload swept it). + var focusedToolCall: (call: HermesToolCall, result: HermesMessage?)? { + guard let id = focusedToolCallId else { return nil } + for group in richChatViewModel.messageGroups { + for msg in group.assistantMessages { + if let call = msg.toolCalls.first(where: { $0.callId == id }) { + return (call, group.toolResults[id]) + } + } + } + return nil + } + /// Absolute project path for the current session, when the chat is /// project-scoped (either started via a project's "New Chat" button /// or resumed from a session that was previously attributed via the @@ -383,18 +418,28 @@ final class ChatViewModel { // projects (creates AGENTS.md with just the block); safe on // template-installed projects (splices the block into // existing AGENTS.md without touching template content). - if let projectPath { - let registry = ProjectDashboardService(context: context).loadRegistry() - if let project = registry.projects.first(where: { $0.path == projectPath }) { - do { - try ProjectAgentContextService(context: context).refresh(for: project) - } catch { - logger.warning("couldn't refresh project context block for \(project.name): \(error.localizedDescription)") - } - } - } - + let contextForPrep = context + let prepLogger = logger Task { @MainActor in + if let projectPath { + // Synchronous file I/O (ProjectDashboardService.loadRegistry + + // ProjectAgentContextService.refresh, which itself walks the + // slash-commands directory) must run off the MainActor — the + // detached task runs the work on the cooperative pool and we + // await it here so the AGENTS.md block lands before client.start(). + await Task.detached { + let registry = ProjectDashboardService(context: contextForPrep).loadRegistry() + guard let project = registry.projects.first(where: { $0.path == projectPath }) else { + return + } + do { + try ProjectAgentContextService(context: contextForPrep).refresh(for: project) + } catch { + prepLogger.warning("couldn't refresh project context block for \(project.name): \(error.localizedDescription)") + } + }.value + } + do { // Start ACP process and event loop FIRST try await client.start() @@ -686,9 +731,78 @@ final class ChatViewModel { func loadRecentSessions() async { let opened = await dataService.open() guard opened else { return } - recentSessions = await dataService.fetchSessions(limit: 10) - sessionPreviews = await dataService.fetchSessionPreviews(limit: 10) + // Bumped from 10 → 50 so the project filter has enough data to + // surface attributed sessions (older attributed sessions were + // getting truncated out of the original limit). Sessions feature + // loads 500; the chat sidebar doesn't need that, but 50 keeps + // the project filter useful without measurable cost. + let fetchedSessions = await dataService.fetchSessions(limit: 50) + let fetchedPreviews = await dataService.fetchSessionPreviews(limit: 50) await dataService.close() + + // Project attribution + registry — single batched off-main read. + let ctx = context + let bundle: (names: [String: String], projects: [ProjectEntry]) = await Task.detached { + let attribution = SessionAttributionService(context: ctx) + let registry = ProjectDashboardService(context: ctx).loadRegistry() + let pathToName = Dictionary( + uniqueKeysWithValues: registry.projects.map { ($0.path, $0.name) } + ) + let map = attribution.load().mappings + var names: [String: String] = [:] + for (sessionID, path) in map { + if let name = pathToName[path] { + names[sessionID] = name + } + } + return (names: names, projects: registry.projects) + }.value + + // Single batched commit — assigning all four observables at once + // means SwiftUI sees one update rather than four staggered ones. + // Eliminates the brief "list flashes / project chips appear + // late" reload artifact during session switches. + recentSessions = fetchedSessions + sessionPreviews = fetchedPreviews + sessionProjectNames = bundle.names + allProjects = bundle.projects + } + + /// Resolved project display name for a recent session, or nil for + /// unattributed (global / quick) sessions. + func projectName(for session: HermesSession) -> String? { + sessionProjectNames[session.id] + } + + /// Rename a session via `hermes sessions rename`. Updates local + /// caches in-place on success so the chat sidebar reflects the new + /// title without a full reload. Same shell command path the + /// SessionsView feature uses. + func renameSession(_ sessionId: String, to newTitle: String) { + let trimmed = newTitle.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let result = context.runHermes(["sessions", "rename", sessionId, trimmed]) + guard result.exitCode == 0 else { return } + if let idx = recentSessions.firstIndex(where: { $0.id == sessionId }) { + recentSessions[idx] = recentSessions[idx].withTitle(trimmed) + } + sessionPreviews[sessionId] = trimmed + } + + /// Delete a session via `hermes sessions delete --yes`. Removes the + /// row from local caches on success and resets the live chat + /// transcript when the deleted session was the active one (so the + /// user isn't left looking at orphaned content). + func deleteSession(_ sessionId: String) { + let result = context.runHermes(["sessions", "delete", "--yes", sessionId]) + guard result.exitCode == 0 else { return } + recentSessions.removeAll { $0.id == sessionId } + sessionPreviews.removeValue(forKey: sessionId) + sessionProjectNames.removeValue(forKey: sessionId) + if richChatViewModel.sessionId == sessionId { + richChatViewModel.reset() + focusedToolCallId = nil + } } func previewFor(_ session: HermesSession) -> String { diff --git a/scarf/scarf/Features/Chat/Views/ChatInspectorPane.swift b/scarf/scarf/Features/Chat/Views/ChatInspectorPane.swift new file mode 100644 index 0000000..ad635a1 --- /dev/null +++ b/scarf/scarf/Features/Chat/Views/ChatInspectorPane.swift @@ -0,0 +1,428 @@ +import AppKit +import SwiftUI +import ScarfCore +import ScarfDesign + +/// Right pane of the 3-pane chat layout — mirrors the inspector in +/// `design/static-site/ui-kit/Chat.jsx` + `ScarfChatView.swift`. Reads +/// `chatViewModel.focusedToolCall` to resolve the focus target. Closing +/// (xmark) clears `focusedToolCallId`. +struct ChatInspectorPane: View { + @Bindable var chatViewModel: ChatViewModel + + @State private var tab: Tab = .details + + enum Tab: String, CaseIterable, Identifiable { + case details, output, raw + var id: String { rawValue } + var label: String { + switch self { + case .details: return "Details" + case .output: return "Output" + case .raw: return "Raw" + } + } + } + + var body: some View { + VStack(spacing: 0) { + if let focus = chatViewModel.focusedToolCall { + header(focus.call) + ScrollView { + Group { + switch tab { + case .details: detailsBody(call: focus.call, result: focus.result) + case .output: outputBody(result: focus.result) + case .raw: rawBody(call: focus.call, result: focus.result) + } + } + .padding(ScarfSpace.s4) + } + footer(call: focus.call, result: focus.result) + } else { + emptyState + } + } + .background(ScarfColor.backgroundSecondary) + } + + // MARK: - Header + + private func header(_ call: HermesToolCall) -> some View { + VStack(alignment: .leading, spacing: ScarfSpace.s2) { + HStack(spacing: ScarfSpace.s2) { + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(toolColor(call).opacity(0.16)) + Image(systemName: call.toolKind.icon) + .font(.system(size: 11)) + .foregroundStyle(toolColor(call)) + } + .frame(width: 24, height: 24) + VStack(alignment: .leading, spacing: 1) { + Text("\(toolLabel(call)) CALL") + .scarfStyle(.captionStrong) + .tracking(0.5) + .foregroundStyle(toolColor(call)) + Text(call.functionName) + .font(ScarfFont.mono) + .fontWeight(.semibold) + .foregroundStyle(ScarfColor.foregroundPrimary) + .lineLimit(1) + } + Spacer() + Button { + chatViewModel.focusedToolCallId = nil + } label: { + Image(systemName: "xmark") + .font(.system(size: 11)) + .foregroundStyle(ScarfColor.foregroundMuted) + .padding(4) + } + .buttonStyle(.plain) + .help("Close inspector") + } + Picker("", selection: $tab) { + ForEach(Tab.allCases) { t in + Text(t.label).tag(t) + } + } + .pickerStyle(.segmented) + .labelsHidden() + } + .padding(.horizontal, ScarfSpace.s4) + .padding(.vertical, ScarfSpace.s3) + .overlay( + Rectangle().fill(ScarfColor.border).frame(height: 1), + alignment: .bottom + ) + } + + // MARK: - Details body + + private func detailsBody(call: HermesToolCall, result: HermesMessage?) -> some View { + VStack(alignment: .leading, spacing: ScarfSpace.s5) { + statusSection(call: call, result: result) + argumentsSection(call: call) + telemetrySection(call: call, result: result) + permissionsSection + } + } + + private func statusSection(call: HermesToolCall, result: HermesMessage?) -> some View { + section("STATUS") { + HStack(spacing: ScarfSpace.s2) { + Image(systemName: statusIcon(call: call, result: result)) + .font(.system(size: 14)) + .foregroundStyle(statusColor(call: call, result: result)) + VStack(alignment: .leading, spacing: 1) { + Text(statusTitle(call: call, result: result)) + .scarfStyle(.captionStrong) + .foregroundStyle(statusColor(call: call, result: result)) + Text(statusSubtitle(call: call)) + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + } + Spacer() + } + .padding(ScarfSpace.s2) + .background( + RoundedRectangle(cornerRadius: 7).fill(statusColor(call: call, result: result).opacity(0.12)) + .overlay( + RoundedRectangle(cornerRadius: 7) + .strokeBorder(statusColor(call: call, result: result).opacity(0.25), lineWidth: 1) + ) + ) + } + } + + private func argumentsSection(call: HermesToolCall) -> some View { + section("ARGUMENTS") { + Text(formatJSON(call.arguments)) + .font(ScarfFont.monoSmall) + .foregroundStyle(ScarfColor.foregroundPrimary) + .textSelection(.enabled) + .padding(ScarfSpace.s2) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 7).fill(ScarfColor.backgroundTertiary) + ) + } + } + + private func telemetrySection(call: HermesToolCall, result: HermesMessage?) -> some View { + section("TELEMETRY") { + VStack(spacing: 0) { + kv("Started", call.startedAt.map { Self.timestampString($0) } ?? "—", mono: true) + kv("Duration", call.duration.map { Self.durationString($0) } ?? "—", mono: true) + if let tokens = result?.tokenCount, tokens > 0 { + kv("Tokens", tokens.formatted(), mono: true) + } else { + kv("Tokens", "—", mono: true) + } + if let exit = call.exitCode { + kv("Exit code", "\(exit)", mono: true, + color: exit == 0 ? ScarfColor.success : ScarfColor.danger) + } else { + kv("Exit code", "—", mono: true) + } + } + } + } + + private var permissionsSection: some View { + section("PERMISSIONS", hint: "Tool gateway policy applied at run time") { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: "checkmark.shield.fill") + .font(.system(size: 11)) + .foregroundStyle(ScarfColor.success) + (Text("Allowed by ") + + Text("scarf-default").font(ScarfFont.monoSmall) + + Text(" profile")) + .scarfStyle(.caption) + } + HStack(spacing: 6) { + Image(systemName: "checkmark") + .font(.system(size: 11)) + .foregroundStyle(ScarfColor.success) + Text("No human approval required") + .scarfStyle(.caption) + } + } + .padding(ScarfSpace.s2) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 7).fill(ScarfColor.backgroundTertiary) + ) + } + } + + // MARK: - Output body + + private func outputBody(result: HermesMessage?) -> some View { + VStack(alignment: .leading, spacing: ScarfSpace.s4) { + Text("OUTPUT") + .scarfStyle(.captionUppercase) + .foregroundStyle(ScarfColor.foregroundMuted) + if let content = result?.content, !content.isEmpty { + Text(content) + .font(ScarfFont.monoSmall) + .foregroundStyle(Color(red: 0.91, green: 0.88, blue: 0.82)) + .textSelection(.enabled) + .padding(ScarfSpace.s3) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(Color(red: 0.07, green: 0.06, blue: 0.05)) + ) + } else { + Text("No output yet.") + .scarfStyle(.body) + .foregroundStyle(ScarfColor.foregroundMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(ScarfSpace.s3) + } + } + } + + // MARK: - Raw body + + private func rawBody(call: HermesToolCall, result: HermesMessage?) -> some View { + VStack(alignment: .leading, spacing: ScarfSpace.s4) { + Text("RAW JSON") + .scarfStyle(.captionUppercase) + .foregroundStyle(ScarfColor.foregroundMuted) + Text(rawJSONString(call: call, result: result)) + .font(ScarfFont.monoSmall) + .foregroundStyle(Color(red: 0.91, green: 0.88, blue: 0.82)) + .textSelection(.enabled) + .padding(ScarfSpace.s3) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(Color(red: 0.07, green: 0.06, blue: 0.05)) + ) + } + } + + // MARK: - Footer + + private func footer(call: HermesToolCall, result: HermesMessage?) -> some View { + HStack(spacing: ScarfSpace.s2) { + Button("Re-run") { + // TODO: wire to a /retry slash command or equivalent ACP path. + // No-op until that lands; button stays so the affordance is + // visible per the design. + } + .buttonStyle(ScarfSecondaryButton()) + .disabled(true) + .help("Re-run isn't wired yet") + .frame(maxWidth: .infinity) + + Button("Copy") { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(rawJSONString(call: call, result: result), forType: .string) + } + .buttonStyle(ScarfGhostButton()) + } + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, ScarfSpace.s4) + .padding(.vertical, ScarfSpace.s2) + .overlay( + Rectangle().fill(ScarfColor.border).frame(height: 1), + alignment: .top + ) + } + + // MARK: - Empty state + + private var emptyState: some View { + VStack(spacing: ScarfSpace.s2) { + Image(systemName: "magnifyingglass") + .font(.system(size: 22)) + .foregroundStyle(ScarfColor.foregroundFaint) + Text("No tool selected") + .scarfStyle(.bodyEmph) + .foregroundStyle(ScarfColor.foregroundPrimary) + Text("Click a tool call in the transcript to inspect it.") + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + .multilineTextAlignment(.center) + .frame(maxWidth: 220) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(ScarfSpace.s5) + } + + // MARK: - Section primitive + + @ViewBuilder + private func section(_ title: String, hint: String? = nil, + @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: ScarfSpace.s2) { + Text(title) + .scarfStyle(.captionUppercase) + .foregroundStyle(ScarfColor.foregroundMuted) + content() + if let hint { + Text(hint) + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundFaint) + } + } + } + + private func kv(_ key: String, _ value: String, mono: Bool, color: Color? = nil) -> some View { + HStack { + Text(key) + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + Spacer() + Text(value) + .font(mono ? ScarfFont.monoSmall : ScarfFont.caption) + .foregroundStyle(color ?? ScarfColor.foregroundPrimary) + } + .padding(.vertical, 5) + .overlay( + Rectangle().fill(ScarfColor.border).frame(height: 1), + alignment: .bottom + ) + } + + // MARK: - Helpers + + private func toolColor(_ call: HermesToolCall) -> Color { + switch call.toolKind { + case .read: return ScarfColor.success + case .edit: return ScarfColor.info + case .execute: return ScarfColor.warning + case .fetch: return ScarfColor.Tool.web + case .browser: return ScarfColor.Tool.search + case .other: return ScarfColor.foregroundMuted + } + } + + private func toolLabel(_ call: HermesToolCall) -> String { + switch call.toolKind { + case .read: return "READ" + case .edit: return "EDIT" + case .execute: return "EXECUTE" + case .fetch: return "FETCH" + case .browser: return "BROWSER" + case .other: return "TOOL" + } + } + + private func statusIcon(call: HermesToolCall, result: HermesMessage?) -> String { + if let exit = call.exitCode { return exit == 0 ? "checkmark.circle.fill" : "xmark.circle.fill" } + if result != nil { return "checkmark.circle.fill" } + return "circle" + } + + private func statusColor(call: HermesToolCall, result: HermesMessage?) -> Color { + if let exit = call.exitCode { return exit == 0 ? ScarfColor.success : ScarfColor.danger } + if result != nil { return ScarfColor.success } + return ScarfColor.foregroundMuted + } + + private func statusTitle(call: HermesToolCall, result: HermesMessage?) -> String { + if let exit = call.exitCode { return exit == 0 ? "Completed" : "Failed" } + if result != nil { return "Completed" } + return "In progress" + } + + private func statusSubtitle(call: HermesToolCall) -> String { + if let exit = call.exitCode { return "Exit \(exit)" } + if let started = call.startedAt { + return "Started \(Self.timestampString(started))" + } + return "Awaiting result" + } + + private func formatJSON(_ raw: String) -> String { + guard let data = raw.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data), + let pretty = try? JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted), + let str = String(data: pretty, encoding: .utf8) else { + return raw + } + return str + } + + private func rawJSONString(call: HermesToolCall, result: HermesMessage?) -> String { + let resultBody: String + if let r = result { + let escaped = r.content.replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + resultBody = "\"\(escaped)\"" + } else { + resultBody = "null" + } + return """ + { + "id": "\(call.callId)", + "type": "tool_use", + "name": "\(call.functionName)", + "input": \(formatJSON(call.arguments)), + "result": { + "exit_code": \(call.exitCode.map { "\($0)" } ?? "null"), + "duration_seconds": \(call.duration.map { String(format: "%.3f", $0) } ?? "null"), + "content": \(resultBody) + } + } + """ + } + + private static func timestampString(_ date: Date) -> String { + let f = DateFormatter() + f.dateFormat = "HH:mm:ss.SSS" + return f.string(from: date) + } + + private static func durationString(_ seconds: TimeInterval) -> String { + if seconds < 1 { return "\(Int(seconds * 1000)) ms" } + return String(format: "%.2f s", seconds) + } +} diff --git a/scarf/scarf/Features/Chat/Views/ChatSessionListPane.swift b/scarf/scarf/Features/Chat/Views/ChatSessionListPane.swift new file mode 100644 index 0000000..514c101 --- /dev/null +++ b/scarf/scarf/Features/Chat/Views/ChatSessionListPane.swift @@ -0,0 +1,379 @@ +import SwiftUI +import ScarfCore +import ScarfDesign + +/// Left pane of the 3-pane chat layout — mirrors the sessions list in +/// `design/static-site/ui-kit/Chat.jsx` + `ScarfChatView.swift`. Reads +/// `chatViewModel.recentSessions` (loaded on the parent view's `.task`), +/// surfaces filter pills + a search field, and renders rows that resume +/// the session on tap. Active row matches `richChat.sessionId`. +struct ChatSessionListPane: View { + @Bindable var chatViewModel: ChatViewModel + @Bindable var richChat: RichChatViewModel + + @State private var searchText: String = "" + /// Project filter — same semantics as the Sessions feature: + /// nil = all projects (no filter), "" = unattributed, any other + /// string matches against `chatViewModel.sessionProjectNames`. + @State private var projectFilter: String? + + @State private var renameTarget: HermesSession? + @State private var renameText: String = "" + @State private var deleteTarget: HermesSession? + + var body: some View { + VStack(spacing: 0) { + header + projectFilterRow + searchField + ScrollView { + LazyVStack(spacing: 0) { + ForEach(visibleSessions) { session in + ChatSessionRow( + session: session, + preview: chatViewModel.previewFor(session), + projectName: chatViewModel.projectName(for: session), + isActive: session.id == richChat.sessionId, + isLive: session.id == richChat.sessionId && richChat.isAgentWorking, + onSelect: { chatViewModel.resumeSession(session.id) } + ) + .contextMenu { + Button("Rename…") { + renameText = chatViewModel.previewFor(session) + renameTarget = session + } + Divider() + Button("Delete…", role: .destructive) { + deleteTarget = session + } + } + } + if visibleSessions.isEmpty { + emptyState + } + } + .padding(.horizontal, 6) + .padding(.bottom, ScarfSpace.s2) + } + footer + } + .background(ScarfColor.backgroundTertiary) + .sheet(item: $renameTarget) { session in + renameSheet(for: session) + } + .confirmationDialog( + deleteTarget.map { "Delete \(chatViewModel.previewFor($0))?" } ?? "", + isPresented: Binding( + get: { deleteTarget != nil }, + set: { if !$0 { deleteTarget = nil } } + ), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let target = deleteTarget { + chatViewModel.deleteSession(target.id) + } + deleteTarget = nil + } + Button("Cancel", role: .cancel) { deleteTarget = nil } + } message: { + Text("This permanently deletes the session and all its messages.") + } + } + + private func renameSheet(for session: HermesSession) -> some View { + VStack(alignment: .leading, spacing: ScarfSpace.s3) { + Text("Rename Session") + .scarfStyle(.headline) + .foregroundStyle(ScarfColor.foregroundPrimary) + ScarfTextField("Session title", text: $renameText) + .onSubmit { commitRename(session) } + HStack { + Button("Cancel") { renameTarget = nil } + .buttonStyle(ScarfGhostButton()) + .keyboardShortcut(.cancelAction) + Spacer() + Button("Rename") { commitRename(session) } + .buttonStyle(ScarfPrimaryButton()) + .keyboardShortcut(.defaultAction) + .disabled(renameText.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding(ScarfSpace.s5) + .frame(width: 380) + } + + private func commitRename(_ session: HermesSession) { + chatViewModel.renameSession(session.id, to: renameText) + renameTarget = nil + } + + // MARK: - Header + + private var header: some View { + HStack(spacing: ScarfSpace.s2) { + Text("Chats") + .scarfStyle(.headline) + .foregroundStyle(ScarfColor.foregroundPrimary) + Spacer() + Button { + chatViewModel.startNewSession() + } label: { + Label("New", systemImage: "plus") + } + .buttonStyle(ScarfPrimaryButton()) + .fixedSize(horizontal: true, vertical: false) + } + .padding(.horizontal, ScarfSpace.s3) + .padding(.top, ScarfSpace.s3) + .padding(.bottom, ScarfSpace.s2) + } + + private var projectFilterRow: some View { + Menu { + Button { + projectFilter = nil + } label: { + Label("All projects", systemImage: "tray.full") + } + Button { + projectFilter = "" + } label: { + Label("Unattributed", systemImage: "questionmark.folder") + } + if !chatViewModel.allProjects.isEmpty { + Divider() + ForEach(chatViewModel.allProjects.sorted { $0.name < $1.name }) { project in + Button { + projectFilter = project.name + } label: { + Label(project.name, systemImage: "folder.fill") + } + } + } + } label: { + HStack(spacing: 6) { + Image(systemName: projectFilterIcon) + .font(.system(size: 11)) + Text(projectFilterLabel) + .scarfStyle(.caption) + .lineLimit(1) + if projectFilter == nil { + Image(systemName: "chevron.down") + .font(.system(size: 9)) + .opacity(0.7) + } + } + .foregroundStyle(projectFilter != nil ? ScarfColor.accentActive : ScarfColor.foregroundPrimary) + .padding(.horizontal, 9) + .padding(.vertical, 3) + .background( + Capsule() + .fill(projectFilter != nil ? ScarfColor.accentTint : ScarfColor.backgroundSecondary) + ) + .overlay( + Capsule() + .strokeBorder( + projectFilter != nil ? ScarfColor.accent : Color.clear, + lineWidth: 1 + ) + ) + } + .menuStyle(.borderlessButton) + .fixedSize() + .padding(.horizontal, ScarfSpace.s3) + .padding(.bottom, ScarfSpace.s2) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var projectFilterIcon: String { + switch projectFilter { + case .none: return "square.stack.3d.up" + case .some(let s) where s.isEmpty: return "questionmark.folder" + default: return "folder.fill" + } + } + + private var projectFilterLabel: String { + switch projectFilter { + case .none: return "All projects" + case .some(let s) where s.isEmpty: return "Unattributed" + case .some(let s): return s + } + } + + private var searchField: some View { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.system(size: 11)) + .foregroundStyle(ScarfColor.foregroundFaint) + TextField("Search…", text: $searchText) + .textFieldStyle(.plain) + .scarfStyle(.caption) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous) + .fill(ScarfColor.backgroundSecondary) + ) + .overlay( + RoundedRectangle(cornerRadius: ScarfRadius.md, style: .continuous) + .strokeBorder(ScarfColor.borderStrong, lineWidth: 1) + ) + .padding(.horizontal, ScarfSpace.s3) + .padding(.bottom, ScarfSpace.s2) + } + + // MARK: - Filtering + + private var visibleSessions: [HermesSession] { + var base = chatViewModel.recentSessions + // Project filter — same semantics as the Sessions feature. + if let filter = projectFilter { + if filter.isEmpty { + base = base.filter { chatViewModel.projectName(for: $0) == nil } + } else { + base = base.filter { chatViewModel.projectName(for: $0) == filter } + } + } + let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { return base } + return base.filter { session in + chatViewModel.previewFor(session).lowercased().contains(trimmed) + } + } + + // MARK: - Empty state + footer + + private var emptyState: some View { + VStack(spacing: 6) { + Image(systemName: "bubble.left.and.bubble.right") + .font(.system(size: 22)) + .foregroundStyle(ScarfColor.foregroundFaint) + Text(emptyMessage) + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(ScarfSpace.s5) + } + + private var emptyMessage: String { + if chatViewModel.recentSessions.isEmpty { + return "No sessions yet — tap New to start one." + } + if projectFilter != nil { + return "No chats in this project (showing the most recent 50)." + } + return "No matches for that search." + } + + private var footer: some View { + HStack(spacing: ScarfSpace.s2) { + Image(systemName: "bubble.left") + .font(.system(size: 10)) + Text("\(chatViewModel.recentSessions.count) chat\(chatViewModel.recentSessions.count == 1 ? "" : "s")") + Spacer() + } + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + .padding(.horizontal, ScarfSpace.s3) + .padding(.vertical, ScarfSpace.s2) + .overlay( + Rectangle() + .fill(ScarfColor.border) + .frame(height: 1), + alignment: .top + ) + } +} + +// MARK: - Row + +private struct ChatSessionRow: View { + let session: HermesSession + let preview: String + let projectName: String? + let isActive: Bool + let isLive: Bool + let onSelect: () -> Void + + @State private var hover = false + + var body: some View { + Button(action: onSelect) { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 7) { + statusDot + Text(preview) + .scarfStyle(.bodyEmph) + .lineLimit(1) + .truncationMode(.tail) + .foregroundStyle(isActive ? ScarfColor.accentActive : ScarfColor.foregroundPrimary) + Spacer(minLength: 0) + if let started = session.startedAt { + Text(started, style: .relative) + .font(ScarfFont.caption2) + .foregroundStyle(ScarfColor.foregroundFaint) + } + } + HStack(spacing: 6) { + if let projectName, !projectName.isEmpty { + HStack(spacing: 3) { + Image(systemName: "folder.fill") + .font(.system(size: 8)) + Text(projectName) + .font(ScarfFont.caption2) + .lineLimit(1) + .truncationMode(.middle) + } + .foregroundStyle(ScarfColor.accentActive) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(Capsule().fill(ScarfColor.accentTint)) + } + Label("\(session.messageCount)", systemImage: "bubble.left") + .scarfStyle(.caption) + if session.toolCallCount > 0 { + Label("\(session.toolCallCount)", systemImage: "wrench") + .scarfStyle(.caption) + } + Spacer(minLength: 0) + } + .foregroundStyle(ScarfColor.foregroundMuted) + .padding(.leading, 14) + } + .padding(.horizontal, 10) + .padding(.vertical, ScarfSpace.s2) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(rowBackground) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hover = $0 } + } + + private var rowBackground: Color { + if isActive { return ScarfColor.accentTint } + if hover { return ScarfColor.border.opacity(0.5) } + return .clear + } + + @ViewBuilder + private var statusDot: some View { + if isLive { + Circle() + .fill(ScarfColor.success) + .frame(width: 7, height: 7) + .overlay(Circle().stroke(ScarfColor.success.opacity(0.20), lineWidth: 2)) + } else { + Circle() + .fill(ScarfColor.foregroundFaint.opacity(0.4)) + .frame(width: 6, height: 6) + } + } +} diff --git a/scarf/scarf/Features/Chat/Views/ChatTranscriptPane.swift b/scarf/scarf/Features/Chat/Views/ChatTranscriptPane.swift new file mode 100644 index 0000000..f6877ac --- /dev/null +++ b/scarf/scarf/Features/Chat/Views/ChatTranscriptPane.swift @@ -0,0 +1,73 @@ +import SwiftUI +import ScarfCore +import ScarfDesign + +/// Middle pane of the 3-pane chat layout — composes the existing +/// `SessionInfoBar` + `RichChatMessageList` + `RichChatInputBar` with +/// no new state of its own. Pulled out of `RichChatView` so the +/// 3-pane HStack is readable. +struct ChatTranscriptPane: View { + @Bindable var richChat: RichChatViewModel + @Bindable var chatViewModel: ChatViewModel + var onSend: (String) -> Void + var isEnabled: Bool + + var body: some View { + VStack(spacing: 0) { + SessionInfoBar( + session: richChat.currentSession, + isWorking: richChat.isGenerating, + acpInputTokens: richChat.acpInputTokens, + acpOutputTokens: richChat.acpOutputTokens, + acpThoughtTokens: richChat.acpThoughtTokens, + projectName: chatViewModel.currentProjectName, + gitBranch: chatViewModel.currentGitBranch + ) + Divider() + + // Always mount RichChatMessageList; empty state lives inside it. + // Swapping between a ContentUnavailableView and the ScrollView + // hierarchy on first message caused a full view tree rebuild, + // which manifests as a white flash. + RichChatMessageList( + groups: richChat.messageGroups, + isWorking: richChat.isGenerating, + isLoadingSession: chatViewModel.isPreparingSession, + scrollTrigger: richChat.scrollTrigger, + turnDurations: richChat.turnDurations + ) + + Divider() + if let hint = richChat.transientHint { + steeringToast(hint) + } + RichChatInputBar( + onSend: onSend, + isEnabled: isEnabled, + commands: richChat.availableCommands, + showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu + ) + } + .background(ScarfColor.backgroundPrimary) + } + + /// Soft pill above the composer that confirms a non-interruptive + /// command (e.g. `/steer`) was received. Auto-clears after a short + /// delay (managed by `ChatViewModel`); presence in the model is what + /// drives this view. + private func steeringToast(_ hint: String) -> some View { + HStack(spacing: 6) { + Image(systemName: "arrowshape.turn.up.right.fill") + .foregroundStyle(ScarfColor.accent) + .scarfStyle(.caption) + Text(hint) + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundPrimary) + Spacer(minLength: 0) + } + .padding(.horizontal, ScarfSpace.s3) + .padding(.vertical, 6) + .background(ScarfColor.accentTint) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } +} diff --git a/scarf/scarf/Features/Chat/Views/ChatView.swift b/scarf/scarf/Features/Chat/Views/ChatView.swift index 9d48610..ae74165 100644 --- a/scarf/scarf/Features/Chat/Views/ChatView.swift +++ b/scarf/scarf/Features/Chat/Views/ChatView.swift @@ -242,42 +242,22 @@ struct ChatView: View { .foregroundStyle(.red) } + // The session list pane on the left is the canonical home + // for browsing + resuming sessions and starting new ones. + // We keep a slim toolbar Menu for the actions that aren't + // expressed by clicking a row: "Return to Active Session" + // (scroll back to the live session) and "Continue Last + // Session" (continue the most-recent without explicit pick). Menu { if viewModel.hasActiveProcess, let activeId = viewModel.richChatViewModel.sessionId { - Button("Return to Active Session (\(activeId.prefix(8))...)") { + Button("Return to Active Session (\(activeId.prefix(8))…)") { viewModel.richChatViewModel.requestScrollToBottom() } Divider() } - Button("New Session") { - viewModel.startNewSession() - } Button("Continue Last Session") { viewModel.continueLastSession() } - if !viewModel.recentSessions.isEmpty { - Divider() - Text("Resume Session") - let activeSessionId = viewModel.richChatViewModel.sessionId - let originSessionId = viewModel.richChatViewModel.originSessionId - ForEach(viewModel.recentSessions) { session in - Button { - viewModel.resumeSession(session.id) - } label: { - HStack { - Text(viewModel.previewFor(session)) - .lineLimit(1) - if let date = session.startedAt { - Text("·") - .foregroundStyle(.secondary) - Text(date, style: .relative) - .foregroundStyle(.secondary) - } - } - } - .disabled(session.id == activeSessionId || session.id == originSessionId) - } - } } label: { Label("Session", systemImage: "play.circle") .font(.caption) diff --git a/scarf/scarf/Features/Chat/Views/RichChatView.swift b/scarf/scarf/Features/Chat/Views/RichChatView.swift index e3080f5..484a49a 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatView.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatView.swift @@ -2,6 +2,19 @@ import SwiftUI import ScarfCore import ScarfDesign +/// 3-pane chat layout — sessions list | transcript | inspector. +/// Mirrors `design/static-site/ui-kit/Chat.jsx` and the +/// `ScarfChatView.ChatRootView` reference component, but composed over +/// the real `ChatViewModel` + `RichChatViewModel` so the live ACP +/// pipeline stays intact. +/// +/// We always render the full 3-pane layout — earlier `ViewThatFits` +/// fallbacks were dropping to transcript-only when the transcript's +/// own ideal width grew mid-load (long code blocks pushed the HStack +/// past the available width and ViewThatFits picked the smallest +/// variant). The window has a sensible minimum (~944 px content area +/// at the default 1100 px window width); narrower than that the user +/// can scroll horizontally inside the panes rather than losing them. struct RichChatView: View { @Bindable var richChat: RichChatViewModel var onSend: (String) -> Void @@ -13,67 +26,21 @@ struct RichChatView: View { private var isACPMode: Bool { chatViewModel.isACPConnected } var body: some View { - VStack(spacing: 0) { - SessionInfoBar( - session: richChat.currentSession, - // Prefer `isGenerating` over the raw `isAgentWorking` - // so the info bar drops the spinner as soon as the - // assistant's reply is visible, even while ACP - // auxiliary work (title gen, usage accounting) is - // still in flight. See RichChatViewModel docs — same - // fix as ScarfGo for pass-1 M7 #4. - isWorking: richChat.isGenerating, - acpInputTokens: richChat.acpInputTokens, - acpOutputTokens: richChat.acpOutputTokens, - acpThoughtTokens: richChat.acpThoughtTokens, - // v2.3: surface the active Scarf project (if any) as - // a folder chip at the start of the bar. Driven by - // ChatViewModel.currentProjectName which is set in - // startACPSession on both new project chats and - // resumed project-attributed sessions. - projectName: chatViewModel.currentProjectName, - // v2.5: git branch indicator alongside the project chip. - gitBranch: chatViewModel.currentGitBranch - ) - Divider() - - // Always mount RichChatMessageList; empty state lives inside it. - // Swapping between a ContentUnavailableView and the ScrollView - // hierarchy on first message caused a full view tree rebuild, - // which manifests as a white flash. - RichChatMessageList( - groups: richChat.messageGroups, - isWorking: richChat.isGenerating, - isLoadingSession: chatViewModel.isPreparingSession, - scrollTrigger: richChat.scrollTrigger, - turnDurations: richChat.turnDurations - ) - - Divider() - if let hint = richChat.transientHint { - steeringToast(hint) - } - RichChatInputBar( - onSend: { text in - onSend(text) - }, - isEnabled: isEnabled, - commands: richChat.availableCommands, - showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu + HStack(spacing: 0) { + ChatSessionListPane(chatViewModel: chatViewModel, richChat: richChat) + .frame(width: 264) + Divider().background(ScarfColor.border) + ChatTranscriptPane( + richChat: richChat, + chatViewModel: chatViewModel, + onSend: onSend, + isEnabled: isEnabled ) + .frame(maxWidth: .infinity) + Divider().background(ScarfColor.border) + ChatInspectorPane(chatViewModel: chatViewModel) + .frame(width: 320) } - // `idealHeight: 500` caps what this subtree REPORTS as its ideal - // height. Load-bearing: RichChatMessageList uses a plain VStack - // (not LazyVStack — see RichChatMessageList.swift:13-24 for the - // rationale) inside a ScrollView, so its natural ideal grows - // with message count. Under the WindowGroup's - // `.windowResizability(.contentMinSize)` policy, that uncapped - // ideal would open the window at a height that exceeds the - // screen on long conversations, pushing the input bar below - // the visible desktop. `maxHeight: .infinity` still lets the - // view fill any larger offered space, and `minHeight: 0` - // allows it to shrink freely — the ideal cap only affects the - // initial-size hint reported up to the window. .frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity) // DB polling fallback for terminal mode only — never overwrite ACP messages .onChange(of: fileWatcher.lastChangeDate) { @@ -82,24 +49,4 @@ struct RichChatView: View { } } } - - /// Soft pill above the composer that confirms a non-interruptive - /// command (e.g. `/steer`) was received. Auto-clears after a short - /// delay (managed by `ChatViewModel`); presence in the model is - /// what drives this view. - private func steeringToast(_ hint: String) -> some View { - HStack(spacing: 6) { - Image(systemName: "arrowshape.turn.up.right.fill") - .foregroundStyle(ScarfColor.accent) - .scarfStyle(.caption) - Text(hint) - .scarfStyle(.caption) - .foregroundStyle(ScarfColor.foregroundPrimary) - Spacer(minLength: 0) - } - .padding(.horizontal, ScarfSpace.s3) - .padding(.vertical, 6) - .background(ScarfColor.accentTint) - .transition(.move(edge: .bottom).combined(with: .opacity)) - } } diff --git a/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift b/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift index 4fee40d..3726d08 100644 --- a/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift +++ b/scarf/scarf/Features/Chat/Views/RichMessageBubble.swift @@ -12,6 +12,8 @@ struct RichMessageBubble: View { /// loaded from `state.db` (no live timing available). var turnDuration: TimeInterval? = nil + @Environment(ChatViewModel.self) private var chatViewModel + var body: some View { if message.isUser { userBubble @@ -163,7 +165,9 @@ struct RichMessageBubble: View { ForEach(message.toolCalls) { call in ToolCallCard( call: call, - result: toolResults[call.callId] + result: toolResults[call.callId], + isFocused: chatViewModel.focusedToolCallId == call.callId, + onFocus: { chatViewModel.focusedToolCallId = call.callId } ) } } diff --git a/scarf/scarf/Features/Chat/Views/ToolCallCard.swift b/scarf/scarf/Features/Chat/Views/ToolCallCard.swift index 2c0bc31..066d871 100644 --- a/scarf/scarf/Features/Chat/Views/ToolCallCard.swift +++ b/scarf/scarf/Features/Chat/Views/ToolCallCard.swift @@ -5,12 +5,22 @@ import ScarfDesign struct ToolCallCard: View { let call: HermesToolCall let result: HermesMessage? + /// True when this card matches `chatViewModel.focusedToolCallId`. + /// Bumps the card's tint + border so users can see at a glance + /// which tool the inspector pane is currently showing. + var isFocused: Bool = false + /// Called when the user clicks the card. Wired to set + /// `chatViewModel.focusedToolCallId = call.callId` from + /// `RichMessageBubble` (Mac). Inline expansion still toggles on the + /// same click — power users get both paths from one gesture. + var onFocus: (() -> Void)? = nil @State private var expanded = false var body: some View { VStack(alignment: .leading, spacing: 6) { Button { + onFocus?() withAnimation(ScarfAnimation.fast) { expanded.toggle() } } label: { HStack(spacing: 9) { @@ -49,10 +59,13 @@ struct ToolCallCard: View { .padding(.vertical, 6) .background( RoundedRectangle(cornerRadius: 7) - .fill(toolColor.opacity(0.10)) + .fill(toolColor.opacity(isFocused ? 0.16 : 0.10)) .overlay( RoundedRectangle(cornerRadius: 7) - .strokeBorder(toolColor.opacity(0.30), lineWidth: 1) + .strokeBorder( + toolColor.opacity(isFocused ? 0.55 : 0.30), + lineWidth: isFocused ? 1.4 : 1 + ) ) ) } diff --git a/scarf/scarf/Features/Projects/Views/ProjectSlashCommandsView.swift b/scarf/scarf/Features/Projects/Views/ProjectSlashCommandsView.swift index af38a59..20ab348 100644 --- a/scarf/scarf/Features/Projects/Views/ProjectSlashCommandsView.swift +++ b/scarf/scarf/Features/Projects/Views/ProjectSlashCommandsView.swift @@ -85,6 +85,7 @@ struct ProjectSlashCommandsView: View { Button("Add Command") { viewModel.beginNew() } .buttonStyle(.borderedProminent) } + .frame(maxWidth: .infinity, maxHeight: .infinity) } else { VStack(spacing: 0) { if let err = viewModel.lastError { diff --git a/scarf/scarf/Localizable.xcstrings b/scarf/scarf/Localizable.xcstrings index 4aad273..a99efb5 100644 --- a/scarf/scarf/Localizable.xcstrings +++ b/scarf/scarf/Localizable.xcstrings @@ -8,6 +8,10 @@ "comment" : "A space.", "isCommentAutoGenerated" : true }, + " profile" : { + "comment" : "A description of a tool gateway policy applied at run time.", + "isCommentAutoGenerated" : true + }, "—" : { "comment" : "A placeholder for a project name.", "isCommentAutoGenerated" : true @@ -117,6 +121,10 @@ } } }, + "%@ CALL" : { + "comment" : "A label that reads \"CALL\" for a tool call.", + "isCommentAutoGenerated" : true + }, "%@ ctx" : { "localizations" : { "de" : { @@ -427,6 +435,18 @@ } } }, + "%lld chat%@" : { + "comment" : "A label displaying the number of chat sessions. The argument is the count of sessions.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$lld chat%2$@" + } + } + } + }, "%lld delivery failure%@" : { "extractionState" : "stale", "localizations" : { @@ -2345,6 +2365,10 @@ } } }, + "Allowed by " : { + "comment" : "A prefix for a tool gateway policy.", + "isCommentAutoGenerated" : true + }, "already gone" : { "comment" : "A tag for a file that is already gone (no longer in the template).", "isCommentAutoGenerated" : true @@ -3937,6 +3961,10 @@ } } }, + "Chats" : { + "comment" : "The title of the chat list.", + "isCommentAutoGenerated" : true + }, "Chats you start here get attributed automatically. Older CLI-started sessions live in the global Sessions sidebar." : { "comment" : "A description of the purpose of the Sessions tab.", "isCommentAutoGenerated" : true @@ -4557,6 +4585,10 @@ }, "CLI" : { + }, + "Click a tool call in the transcript to inspect it." : { + "comment" : "A description of the content of the chat inspector pane.", + "isCommentAutoGenerated" : true }, "Click Add to connect to a remote Hermes installation over SSH." : { "localizations" : { @@ -4758,6 +4790,10 @@ } } }, + "Close inspector" : { + "comment" : "A button that closes the inspector.", + "isCommentAutoGenerated" : true + }, "Close Window" : { "localizations" : { "de" : { @@ -12686,6 +12722,10 @@ } } }, + "New" : { + "comment" : "A button that starts a new chat session.", + "isCommentAutoGenerated" : true + }, "New Chat" : { "comment" : "A button that starts a new chat session.", "isCommentAutoGenerated" : true @@ -13447,6 +13487,10 @@ } } }, + "No human approval required" : { + "comment" : "A description of a tool's permission status.", + "isCommentAutoGenerated" : true + }, "No matches for \"%@\"." : { "comment" : "A message that appears when a search yields no results. The argument is the search term.", "isCommentAutoGenerated" : true @@ -13571,6 +13615,10 @@ } } }, + "No output yet." : { + "comment" : "A message displayed when a tool call has not yet produced output.", + "isCommentAutoGenerated" : true + }, "No paired users" : { "localizations" : { "de" : { @@ -14041,6 +14089,10 @@ } } }, + "No tool selected" : { + "comment" : "A message displayed when there is no currently selected tool call.", + "isCommentAutoGenerated" : true + }, "No Updates" : { "localizations" : { "de" : { @@ -14968,6 +15020,10 @@ } } }, + "OUTPUT" : { + "comment" : "A label displayed above the chat output.", + "isCommentAutoGenerated" : true + }, "Overview" : { "extractionState" : "stale", "localizations" : { @@ -16474,6 +16530,10 @@ } } }, + "RAW JSON" : { + "comment" : "A label displayed above the raw JSON output of a tool.", + "isCommentAutoGenerated" : true + }, "Raw remote output (for debugging)" : { "localizations" : { "de" : { @@ -16554,6 +16614,10 @@ } } }, + "Re-run isn't wired yet" : { + "comment" : "A tooltip for the \"Re-run\" button.", + "isCommentAutoGenerated" : true + }, "Read" : { "extractionState" : "stale", "localizations" : { @@ -18010,6 +18074,7 @@ } }, "Resume Session" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -18090,6 +18155,7 @@ } }, "Return to Active Session (%@...)" : { + "extractionState" : "stale", "localizations" : { "de" : { "stringUnit" : { @@ -18129,6 +18195,10 @@ } } }, + "Return to Active Session (%@…)" : { + "comment" : "A button that scrolls to the bottom of the chat view.", + "isCommentAutoGenerated" : true + }, "Reveal" : { "localizations" : { "de" : { @@ -18803,6 +18873,10 @@ "comment" : "A description of how a template will be installed.", "isCommentAutoGenerated" : true }, + "scarf-default" : { + "comment" : "A tool gateway policy applied at run time.", + "isCommentAutoGenerated" : true + }, "schedule: %@" : { "comment" : "A cron schedule, e.g. \"every 10 minutes\".", "isCommentAutoGenerated" : true @@ -22304,6 +22378,9 @@ "This may take a few seconds." : { "comment" : "A description of the time it takes to connect to Nous Portal.", "isCommentAutoGenerated" : true + }, + "This permanently deletes the session and all its messages." : { + }, "This project wasn't installed from a schemaful template." : {