feat(chat): slash-command menu + scroll/layout fixes

- 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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-20 16:35:53 -07:00
parent 0384c6ef17
commit a68e0c5f42
7 changed files with 396 additions and 97 deletions
@@ -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
}
@@ -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<String> = []
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<String> = []
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()
@@ -3,16 +3,38 @@ 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 {
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 supportsCompress {
if showCompressButton {
Button {
compressFocus = ""
showCompressSheet = true
@@ -45,10 +67,37 @@ struct RichChatInputBar: View {
.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
}
@@ -66,7 +115,14 @@ struct RichChatInputBar: View {
}
.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
}
}
@@ -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
}
@@ -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
@@ -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)
}
}
@@ -25,9 +25,16 @@ 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 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.") {
@@ -43,9 +50,6 @@ final class QuickCommandsViewModel {
}
return byName.map { HermesQuickCommand(name: $0.key, type: $0.value.type, command: $0.value.command) }
.sorted { $0.name < $1.name }
}()
await MainActor.run { [weak self] in self?.commands = result }
}
}
/// Check for obviously destructive shell strings. Display-only; we do not block.