From a68e0c5f42fc01ea542ff8ed4d726920e05bf18c Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Mon, 20 Apr 2026 16:35:53 -0700 Subject: [PATCH] feat(chat): slash-command menu + scroll/layout fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add floating slash-command menu driven by ACP available_commands_update and user-defined quick_commands from config.yaml. ↑/↓ navigate, Tab or Enter completes, Esc dismisses. Commands with argument hints insert a trailing space so the user can type the argument. - New HermesSlashCommand model carries name/description/argumentHint/source; RichChatViewModel stores ACP + quick_commands separately and merges them for the menu. QuickCommandsViewModel exposes a reusable static loader. - Menu renders as a sibling above the input HStack (not a popover or overlay) — guaranteed to render regardless of focus/z-order quirks. - Hide the dedicated /compress button once the menu has more than one command; keep it as a fallback when only /compress is advertised. - Fix long-standing "session loads with whitespace, must scroll up to see chat" bug by switching LazyVStack → VStack in RichChatMessageList. LazyVStack's estimated row heights were fooling .defaultScrollAnchor(.bottom) into overshooting real content; VStack measures every row upfront so the anchor has real heights to work with. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Core/Models/HermesSlashCommand.swift | 17 ++ .../Chat/ViewModels/RichChatViewModel.swift | 76 ++++++- .../Chat/Views/RichChatInputBar.swift | 207 +++++++++++++----- .../Chat/Views/RichChatMessageList.swift | 32 +-- .../Features/Chat/Views/RichChatView.swift | 3 +- .../Chat/Views/SlashCommandMenu.swift | 116 ++++++++++ .../ViewModels/QuickCommandsViewModel.swift | 42 ++-- 7 files changed, 396 insertions(+), 97 deletions(-) create mode 100644 scarf/scarf/Core/Models/HermesSlashCommand.swift create mode 100644 scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift diff --git a/scarf/scarf/Core/Models/HermesSlashCommand.swift b/scarf/scarf/Core/Models/HermesSlashCommand.swift new file mode 100644 index 0000000..e0702a0 --- /dev/null +++ b/scarf/scarf/Core/Models/HermesSlashCommand.swift @@ -0,0 +1,17 @@ +import Foundation + +/// A slash command available in chat. Sourced either from the ACP server +/// (`available_commands_update`) or from user-defined `quick_commands` in +/// `config.yaml`. +struct HermesSlashCommand: Identifiable, Sendable, Equatable { + enum Source: Sendable, Equatable { + case acp + case quickCommand + } + + var id: String { name } + let name: String + let description: String + let argumentHint: String? + let source: Source +} diff --git a/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift index 7e6e610..0728dbd 100644 --- a/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/RichChatViewModel.swift @@ -31,6 +31,7 @@ final class RichChatViewModel { init(context: ServerContext = .local) { self.context = context self.dataService = HermesDataService(context: context) + loadQuickCommands() } @@ -49,9 +50,21 @@ final class RichChatViewModel { private(set) var acpCachedReadTokens = 0 /// Slash commands advertised by the ACP server via `available_commands_update`. - private(set) var availableCommandNames: Set = [] + private(set) var acpCommands: [HermesSlashCommand] = [] + /// User-defined commands parsed from `config.yaml` `quick_commands`. + private(set) var quickCommands: [HermesSlashCommand] = [] - var supportsCompress: Bool { availableCommandNames.contains("compress") } + /// Merged list, ACP-first, de-duplicated by name. + var availableCommands: [HermesSlashCommand] { + let acpNames = Set(acpCommands.map(\.name)) + return acpCommands + quickCommands.filter { !acpNames.contains($0.name) } + } + + var supportsCompress: Bool { availableCommands.contains { $0.name == "compress" } } + + /// True when the menu carries more than just `/compress` — used to hide + /// the dedicated compress button in favor of the full slash menu. + var hasBroaderCommandMenu: Bool { availableCommands.count > 1 } var hasMessages: Bool { !messages.isEmpty } @@ -105,8 +118,9 @@ final class RichChatViewModel { acpOutputTokens = 0 acpThoughtTokens = 0 acpCachedReadTokens = 0 - availableCommandNames = [] + acpCommands = [] pendingPermission = nil + loadQuickCommands() } func setSessionId(_ id: String?) { @@ -181,19 +195,59 @@ final class RichChatViewModel { case .connectionLost(let reason): handleConnectionLost(reason: reason) case .availableCommands(_, let commands): - var names: Set = [] - for entry in commands { - if let name = entry["name"] as? String { - // Hermes sends names either as "compress" or "/compress" - names.insert(name.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) - } - } - availableCommandNames = names + acpCommands = parseACPCommands(commands) case .unknown: break } } + private func parseACPCommands(_ commands: [[String: Any]]) -> [HermesSlashCommand] { + var result: [HermesSlashCommand] = [] + for entry in commands { + guard let rawName = entry["name"] as? String else { continue } + // Hermes sends names either as "compress" or "/compress" + let name = rawName.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard !name.isEmpty else { continue } + let description = (entry["description"] as? String) ?? "" + var hint: String? = nil + if let input = entry["input"] as? [String: Any], + let h = input["hint"] as? String, + !h.isEmpty { + hint = h + } + result.append(HermesSlashCommand( + name: name, + description: description, + argumentHint: hint, + source: .acp + )) + } + return result + } + + /// Load `quick_commands` from `config.yaml` off the main actor and publish + /// them as slash commands. Safe to call repeatedly — replaces the existing list. + func loadQuickCommands() { + let ctx = context + Task.detached { [weak self] in + let loaded = QuickCommandsViewModel.loadQuickCommands(context: ctx) + let mapped = loaded.map { qc -> HermesSlashCommand in + let truncated = qc.command.count > 60 + ? String(qc.command.prefix(60)) + "…" + : qc.command + return HermesSlashCommand( + name: qc.name, + description: "Run: \(truncated)", + argumentHint: nil, + source: .quickCommand + ) + } + await MainActor.run { [weak self] in + self?.quickCommands = mapped + } + } + } + private func appendMessageChunk(text: String) { streamingAssistantText += text upsertStreamingMessage() diff --git a/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift b/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift index 5656376..76f04f9 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift @@ -3,70 +3,126 @@ import SwiftUI struct RichChatInputBar: View { let onSend: (String) -> Void let isEnabled: Bool - var supportsCompress: Bool = false + var commands: [HermesSlashCommand] = [] + var showCompressButton: Bool = false @State private var text = "" @State private var showCompressSheet = false @State private var compressFocus = "" + @State private var showMenu = false + @State private var selectedIndex = 0 @FocusState private var isFocused: Bool var body: some View { - HStack(alignment: .bottom, spacing: 8) { - if supportsCompress { + VStack(alignment: .leading, spacing: 0) { + if showMenu { + SlashCommandMenu( + commands: commands, + query: menuQuery, + selectedIndex: $selectedIndex, + onSelect: insertCommand + ) + .background(.regularMaterial) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(.separator, lineWidth: 0.5) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .shadow(color: .black.opacity(0.2), radius: 8, x: 0, y: 2) + .padding(.horizontal, 12) + .padding(.top, 8) + } + + HStack(alignment: .bottom, spacing: 8) { + if showCompressButton { + Button { + compressFocus = "" + showCompressSheet = true + } label: { + Image(systemName: "rectangle.compress.vertical") + .font(.title3) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .disabled(!isEnabled) + .help("Compress conversation (/compress)") + } + + TextEditor(text: $text) + .font(.body) + .scrollContentBackground(.hidden) + .focused($isFocused) + .frame(minHeight: 28, maxHeight: 120) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(.quaternary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(alignment: .topLeading) { + if text.isEmpty { + Text("Message Hermes...") + .foregroundStyle(.tertiary) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .allowsHitTesting(false) + } + } + .onKeyPress(.upArrow, phases: .down) { _ in + guard showMenu, !filteredCommands.isEmpty else { return .ignored } + let n = filteredCommands.count + selectedIndex = (selectedIndex - 1 + n) % n + return .handled + } + .onKeyPress(.downArrow, phases: .down) { _ in + guard showMenu, !filteredCommands.isEmpty else { return .ignored } + let n = filteredCommands.count + selectedIndex = (selectedIndex + 1) % n + return .handled + } + .onKeyPress(.tab, phases: .down) { _ in + guard showMenu, + let command = filteredCommands[safe: selectedIndex] else { return .ignored } + insertCommand(command) + return .handled + } + .onKeyPress(.escape, phases: .down) { _ in + guard showMenu else { return .ignored } + showMenu = false + return .handled + } + .onKeyPress(.return, phases: .down) { press in + if press.modifiers.contains(.shift) { + return .ignored + } + if showMenu, let command = filteredCommands[safe: selectedIndex] { + insertCommand(command) + return .handled + } + send() + return .handled + } + Button { - compressFocus = "" - showCompressSheet = true + send() } label: { - Image(systemName: "rectangle.compress.vertical") - .font(.title3) - .foregroundStyle(.secondary) + Image(systemName: "arrow.up.circle.fill") + .font(.title2) + .foregroundStyle(canSend ? Color.accentColor : .secondary) } .buttonStyle(.plain) - .disabled(!isEnabled) - .help("Compress conversation (/compress)") + .disabled(!canSend) + .help("Send message (Enter)") } - - TextEditor(text: $text) - .font(.body) - .scrollContentBackground(.hidden) - .focused($isFocused) - .frame(minHeight: 28, maxHeight: 120) - .fixedSize(horizontal: false, vertical: true) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.quaternary.opacity(0.5)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay(alignment: .topLeading) { - if text.isEmpty { - Text("Message Hermes...") - .foregroundStyle(.tertiary) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .allowsHitTesting(false) - } - } - .onKeyPress(.return, phases: .down) { press in - if press.modifiers.contains(.shift) { - return .ignored - } - send() - return .handled - } - - Button { - send() - } label: { - Image(systemName: "arrow.up.circle.fill") - .font(.title2) - .foregroundStyle(canSend ? Color.accentColor : .secondary) - } - .buttonStyle(.plain) - .disabled(!canSend) - .help("Send message (Enter)") + .padding(.horizontal, 12) + .padding(.vertical, 8) } - .padding(.horizontal, 12) - .padding(.vertical, 8) .background(.bar) + .onChange(of: text) { _, _ in + updateMenuState() + } + .onChange(of: commands.map(\.id)) { _, _ in + updateMenuState() + } .sheet(isPresented: $showCompressSheet) { compressSheet } @@ -101,10 +157,61 @@ struct RichChatInputBar: View { isEnabled && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + /// Show the slash menu only while the user is typing the command token: + /// text starts with `/` and contains no whitespace (space or newline). + private var shouldShowMenu: Bool { + guard text.hasPrefix("/") else { return false } + return !text.contains(" ") && !text.contains("\n") + } + + private var menuQuery: String { + guard text.hasPrefix("/") else { return "" } + return String(text.dropFirst()) + } + + private var filteredCommands: [HermesSlashCommand] { + SlashCommandMenu.filter(commands: commands, query: menuQuery) + } + + private func updateMenuState() { + let shouldShow = shouldShowMenu + if shouldShow != showMenu { + showMenu = shouldShow + } + // Re-clamp selection whenever the filtered list may have shrunk. + let count = filteredCommands.count + if count == 0 { + selectedIndex = 0 + } else if selectedIndex >= count { + selectedIndex = count - 1 + } else if selectedIndex < 0 { + selectedIndex = 0 + } + } + + private func insertCommand(_ command: HermesSlashCommand) { + if command.argumentHint != nil { + text = "/\(command.name) " + } else { + text = "/\(command.name)" + } + showMenu = false + selectedIndex = 0 + isFocused = true + } + private func send() { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, isEnabled else { return } onSend(trimmed) text = "" + showMenu = false + selectedIndex = 0 + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil } } diff --git a/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift b/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift index 0c77410..41ec060 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatMessageList.swift @@ -6,27 +6,27 @@ struct RichChatMessageList: View { /// External trigger to force a scroll-to-bottom (e.g., from "Return to Active Session"). var scrollTrigger: UUID = UUID() - /// Why `.defaultScrollAnchor(.bottom)` *alone* and no `proxy.scrollTo`. + /// Scrolling strategy: plain `VStack` (not `LazyVStack`) plus + /// `.defaultScrollAnchor(.bottom)`. /// - /// `.defaultScrollAnchor(.bottom)` tells SwiftUI to pin the viewport to - /// the bottom of the content automatically — as messages stream in or - /// new turns arrive, the scroll position tracks the bottom edge. + /// `LazyVStack` was causing the classic "loaded session shows whitespace + /// and the chat is above" bug: lazy rows return estimated heights before + /// they render, `.defaultScrollAnchor(.bottom)` positions the viewport + /// at the *estimated* bottom (which overshoots the real content), and + /// when rows materialize and real heights land, the viewport ends up + /// past the content. Attempts to correct via `proxy.scrollTo(lastID)` + /// failed because unrendered rows have no resolvable ID. /// - /// We used to also call `proxy.scrollTo(lastID, anchor: .bottom)` from - /// six different `onChange` handlers during streaming. The two - /// mechanisms fought each other: the ScrollViewReader can resolve an ID - /// to a position **before** LazyVStack has finished laying out that - /// row, so `scrollTo` would land past the actual content — the - /// "viewport showing whitespace, chat is above" symptom. Removing the - /// manual scroll and trusting `defaultScrollAnchor` eliminates the race. - /// - /// The only remaining explicit scroll is `scrollTrigger` for the "Return - /// to Active Session" button; that fires rarely, after layout has - /// settled, so the overshoot doesn't happen. + /// Switching to `VStack` materializes every row immediately, so + /// `.defaultScrollAnchor(.bottom)` has real heights to work with and + /// can't overshoot. For typical Hermes sessions (<500 messages) the + /// first-render cost is acceptable. If ever needed for huge sessions + /// we can reintroduce lazy with a preference-key-based height + /// measurement, but that's a much larger change. var body: some View { ScrollViewReader { proxy in ScrollView { - LazyVStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 16) { if groups.isEmpty && !isWorking { emptyState } diff --git a/scarf/scarf/Features/Chat/Views/RichChatView.swift b/scarf/scarf/Features/Chat/Views/RichChatView.swift index f4e4f85..0cfd44b 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatView.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatView.swift @@ -37,7 +37,8 @@ struct RichChatView: View { onSend(text) }, isEnabled: isEnabled, - supportsCompress: richChat.supportsCompress + commands: richChat.availableCommands, + showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu ) } // DB polling fallback for terminal mode only — never overwrite ACP messages diff --git a/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift b/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift new file mode 100644 index 0000000..467a934 --- /dev/null +++ b/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift @@ -0,0 +1,116 @@ +import SwiftUI + +/// Floating menu of available slash commands shown above the chat input when +/// the user types `/` as the first character. Read-only list — the parent +/// owns selection state and insertion. +struct SlashCommandMenu: View { + let commands: [HermesSlashCommand] + let query: String + @Binding var selectedIndex: Int + var onSelect: (HermesSlashCommand) -> Void + + var filtered: [HermesSlashCommand] { + Self.filter(commands: commands, query: query) + } + + static func filter(commands: [HermesSlashCommand], query: String) -> [HermesSlashCommand] { + let q = query.lowercased() + if q.isEmpty { return commands } + let prefix = commands.filter { $0.name.lowercased().hasPrefix(q) } + if !prefix.isEmpty { return prefix } + return commands.filter { $0.description.lowercased().contains(q) } + } + + var body: some View { + let items = filtered + if commands.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("No commands available") + .font(.callout) + .foregroundStyle(.secondary) + Text("The agent hasn't advertised any slash commands yet. Keep typing to send as a message, or press Esc.") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(12) + .frame(minWidth: 360, alignment: .leading) + } else if items.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("No matching commands") + .font(.callout) + .foregroundStyle(.secondary) + Text("Keep typing to send as a message, or press Esc.") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(12) + .frame(minWidth: 360, alignment: .leading) + } else { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(items.enumerated()), id: \.element.id) { index, command in + SlashCommandRow( + command: command, + isSelected: index == selectedIndex + ) + .id(index) + .contentShape(Rectangle()) + .onTapGesture { + selectedIndex = index + onSelect(command) + } + } + } + } + .frame(minWidth: 360, maxHeight: 260) + .onChange(of: selectedIndex) { _, newValue in + withAnimation(.easeOut(duration: 0.1)) { + proxy.scrollTo(newValue, anchor: .center) + } + } + } + } + } +} + +private struct SlashCommandRow: View { + let command: HermesSlashCommand + let isSelected: Bool + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text("/\(command.name)") + .font(.system(.body, design: .monospaced)) + .fontWeight(.semibold) + if let hint = command.argumentHint { + Text("<\(hint)>") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.tertiary) + } + if command.source == .quickCommand { + Text("user") + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(.quaternary.opacity(0.8)) + .clipShape(Capsule()) + .foregroundStyle(.secondary) + } + } + if !command.description.isEmpty { + Text(command.description) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(isSelected ? Color.accentColor.opacity(0.15) : Color.clear) + } +} diff --git a/scarf/scarf/Features/QuickCommands/ViewModels/QuickCommandsViewModel.swift b/scarf/scarf/Features/QuickCommands/ViewModels/QuickCommandsViewModel.swift index 2dbfdeb..9e948a3 100644 --- a/scarf/scarf/Features/QuickCommands/ViewModels/QuickCommandsViewModel.swift +++ b/scarf/scarf/Features/QuickCommands/ViewModels/QuickCommandsViewModel.swift @@ -25,29 +25,33 @@ final class QuickCommandsViewModel { func load() { let ctx = context Task.detached { [weak self] in - let yaml = ctx.readText(ctx.paths.configYAML) - let result: [HermesQuickCommand] = { - guard let yaml else { return [] } - let parsed = HermesFileService.parseNestedYAML(yaml) - var byName: [String: (type: String, command: String)] = [:] - for (key, value) in parsed.values where key.hasPrefix("quick_commands.") { - let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false) - guard parts.count == 3 else { continue } - let name = String(parts[1]) - let field = String(parts[2]) - var existing = byName[name] ?? (type: "exec", command: "") - let stripped = HermesFileService.stripYAMLQuotes(value) - if field == "type" { existing.type = stripped } - if field == "command" { existing.command = stripped } - byName[name] = existing - } - return byName.map { HermesQuickCommand(name: $0.key, type: $0.value.type, command: $0.value.command) } - .sorted { $0.name < $1.name } - }() + let result = Self.loadQuickCommands(context: ctx) await MainActor.run { [weak self] in self?.commands = result } } } + /// Parse `quick_commands` from `config.yaml` on the given context. Safe to + /// call from any actor — performs synchronous file I/O, so dispatch from a + /// detached task when called from `@MainActor`. + nonisolated static func loadQuickCommands(context: ServerContext) -> [HermesQuickCommand] { + guard let yaml = context.readText(context.paths.configYAML) else { return [] } + let parsed = HermesFileService.parseNestedYAML(yaml) + var byName: [String: (type: String, command: String)] = [:] + for (key, value) in parsed.values where key.hasPrefix("quick_commands.") { + let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false) + guard parts.count == 3 else { continue } + let name = String(parts[1]) + let field = String(parts[2]) + var existing = byName[name] ?? (type: "exec", command: "") + let stripped = HermesFileService.stripYAMLQuotes(value) + if field == "type" { existing.type = stripped } + if field == "command" { existing.command = stripped } + byName[name] = existing + } + return byName.map { HermesQuickCommand(name: $0.key, type: $0.value.type, command: $0.value.command) } + .sorted { $0.name < $1.name } + } + /// Check for obviously destructive shell strings. Display-only; we do not block. static func isDangerous(_ command: String) -> Bool { let lowered = command.lowercased()