mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
@@ -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) {
|
init(context: ServerContext = .local) {
|
||||||
self.context = context
|
self.context = context
|
||||||
self.dataService = HermesDataService(context: context)
|
self.dataService = HermesDataService(context: context)
|
||||||
|
loadQuickCommands()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -49,9 +50,21 @@ final class RichChatViewModel {
|
|||||||
private(set) var acpCachedReadTokens = 0
|
private(set) var acpCachedReadTokens = 0
|
||||||
|
|
||||||
/// Slash commands advertised by the ACP server via `available_commands_update`.
|
/// 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 }
|
var hasMessages: Bool { !messages.isEmpty }
|
||||||
|
|
||||||
@@ -105,8 +118,9 @@ final class RichChatViewModel {
|
|||||||
acpOutputTokens = 0
|
acpOutputTokens = 0
|
||||||
acpThoughtTokens = 0
|
acpThoughtTokens = 0
|
||||||
acpCachedReadTokens = 0
|
acpCachedReadTokens = 0
|
||||||
availableCommandNames = []
|
acpCommands = []
|
||||||
pendingPermission = nil
|
pendingPermission = nil
|
||||||
|
loadQuickCommands()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setSessionId(_ id: String?) {
|
func setSessionId(_ id: String?) {
|
||||||
@@ -181,19 +195,59 @@ final class RichChatViewModel {
|
|||||||
case .connectionLost(let reason):
|
case .connectionLost(let reason):
|
||||||
handleConnectionLost(reason: reason)
|
handleConnectionLost(reason: reason)
|
||||||
case .availableCommands(_, let commands):
|
case .availableCommands(_, let commands):
|
||||||
var names: Set<String> = []
|
acpCommands = parseACPCommands(commands)
|
||||||
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
|
|
||||||
case .unknown:
|
case .unknown:
|
||||||
break
|
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) {
|
private func appendMessageChunk(text: String) {
|
||||||
streamingAssistantText += text
|
streamingAssistantText += text
|
||||||
upsertStreamingMessage()
|
upsertStreamingMessage()
|
||||||
|
|||||||
@@ -3,16 +3,38 @@ import SwiftUI
|
|||||||
struct RichChatInputBar: View {
|
struct RichChatInputBar: View {
|
||||||
let onSend: (String) -> Void
|
let onSend: (String) -> Void
|
||||||
let isEnabled: Bool
|
let isEnabled: Bool
|
||||||
var supportsCompress: Bool = false
|
var commands: [HermesSlashCommand] = []
|
||||||
|
var showCompressButton: Bool = false
|
||||||
|
|
||||||
@State private var text = ""
|
@State private var text = ""
|
||||||
@State private var showCompressSheet = false
|
@State private var showCompressSheet = false
|
||||||
@State private var compressFocus = ""
|
@State private var compressFocus = ""
|
||||||
|
@State private var showMenu = false
|
||||||
|
@State private var selectedIndex = 0
|
||||||
@FocusState private var isFocused: Bool
|
@FocusState private var isFocused: Bool
|
||||||
|
|
||||||
var body: some View {
|
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) {
|
HStack(alignment: .bottom, spacing: 8) {
|
||||||
if supportsCompress {
|
if showCompressButton {
|
||||||
Button {
|
Button {
|
||||||
compressFocus = ""
|
compressFocus = ""
|
||||||
showCompressSheet = true
|
showCompressSheet = true
|
||||||
@@ -45,10 +67,37 @@ struct RichChatInputBar: View {
|
|||||||
.allowsHitTesting(false)
|
.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
|
.onKeyPress(.return, phases: .down) { press in
|
||||||
if press.modifiers.contains(.shift) {
|
if press.modifiers.contains(.shift) {
|
||||||
return .ignored
|
return .ignored
|
||||||
}
|
}
|
||||||
|
if showMenu, let command = filteredCommands[safe: selectedIndex] {
|
||||||
|
insertCommand(command)
|
||||||
|
return .handled
|
||||||
|
}
|
||||||
send()
|
send()
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
@@ -66,7 +115,14 @@ struct RichChatInputBar: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
.background(.bar)
|
.background(.bar)
|
||||||
|
.onChange(of: text) { _, _ in
|
||||||
|
updateMenuState()
|
||||||
|
}
|
||||||
|
.onChange(of: commands.map(\.id)) { _, _ in
|
||||||
|
updateMenuState()
|
||||||
|
}
|
||||||
.sheet(isPresented: $showCompressSheet) {
|
.sheet(isPresented: $showCompressSheet) {
|
||||||
compressSheet
|
compressSheet
|
||||||
}
|
}
|
||||||
@@ -101,10 +157,61 @@ struct RichChatInputBar: View {
|
|||||||
isEnabled && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
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() {
|
private func send() {
|
||||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty, isEnabled else { return }
|
guard !trimmed.isEmpty, isEnabled else { return }
|
||||||
onSend(trimmed)
|
onSend(trimmed)
|
||||||
text = ""
|
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").
|
/// External trigger to force a scroll-to-bottom (e.g., from "Return to Active Session").
|
||||||
var scrollTrigger: UUID = UUID()
|
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
|
/// `LazyVStack` was causing the classic "loaded session shows whitespace
|
||||||
/// the bottom of the content automatically — as messages stream in or
|
/// and the chat is above" bug: lazy rows return estimated heights before
|
||||||
/// new turns arrive, the scroll position tracks the bottom edge.
|
/// 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
|
/// Switching to `VStack` materializes every row immediately, so
|
||||||
/// six different `onChange` handlers during streaming. The two
|
/// `.defaultScrollAnchor(.bottom)` has real heights to work with and
|
||||||
/// mechanisms fought each other: the ScrollViewReader can resolve an ID
|
/// can't overshoot. For typical Hermes sessions (<500 messages) the
|
||||||
/// to a position **before** LazyVStack has finished laying out that
|
/// first-render cost is acceptable. If ever needed for huge sessions
|
||||||
/// row, so `scrollTo` would land past the actual content — the
|
/// we can reintroduce lazy with a preference-key-based height
|
||||||
/// "viewport showing whitespace, chat is above" symptom. Removing the
|
/// measurement, but that's a much larger change.
|
||||||
/// 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.
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollViewReader { proxy in
|
ScrollViewReader { proxy in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
LazyVStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
if groups.isEmpty && !isWorking {
|
if groups.isEmpty && !isWorking {
|
||||||
emptyState
|
emptyState
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ struct RichChatView: View {
|
|||||||
onSend(text)
|
onSend(text)
|
||||||
},
|
},
|
||||||
isEnabled: isEnabled,
|
isEnabled: isEnabled,
|
||||||
supportsCompress: richChat.supportsCompress
|
commands: richChat.availableCommands,
|
||||||
|
showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// DB polling fallback for terminal mode only — never overwrite ACP messages
|
// 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() {
|
func load() {
|
||||||
let ctx = context
|
let ctx = context
|
||||||
Task.detached { [weak self] in
|
Task.detached { [weak self] in
|
||||||
let yaml = ctx.readText(ctx.paths.configYAML)
|
let result = Self.loadQuickCommands(context: ctx)
|
||||||
let result: [HermesQuickCommand] = {
|
await MainActor.run { [weak self] in self?.commands = result }
|
||||||
guard let yaml else { return [] }
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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)
|
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||||
var byName: [String: (type: String, command: String)] = [:]
|
var byName: [String: (type: String, command: String)] = [:]
|
||||||
for (key, value) in parsed.values where key.hasPrefix("quick_commands.") {
|
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) }
|
return byName.map { HermesQuickCommand(name: $0.key, type: $0.value.type, command: $0.value.command) }
|
||||||
.sorted { $0.name < $1.name }
|
.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.
|
/// Check for obviously destructive shell strings. Display-only; we do not block.
|
||||||
|
|||||||
Reference in New Issue
Block a user