diff --git a/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift index 3d46f67..9a001bb 100644 --- a/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift +++ b/scarf/scarf/Features/Settings/Views/Tabs/AuxiliaryTab.swift @@ -61,65 +61,71 @@ struct AuxiliaryTab: View { /// fall-through "Other tasks in config.yaml" section. private var unknownTasks: [String] { let known = Set(tasks.map(\.key)) + let found = Self.parseAuxTaskNames(from: viewModel.rawConfigYAML) + return found.subtracting(known).sorted() + } + + /// Walk the raw config.yaml for the top-level `auxiliary:` block + /// and collect ONLY direct-child task names (not the leaf + /// fields underneath them like `provider`, `model`, `api_key`). + /// Static + `internal` so unit tests can drive it with fixture + /// strings without standing up a SettingsViewModel. + /// + /// Handles both 2-space and 4-space indent styles. Tolerates + /// blank lines and comments. Stops collecting when indent + /// drops back to or below the `auxiliary:` line — same shape + /// the YAML parser uses to decide block boundaries. + static func parseAuxTaskNames(from yaml: String) -> Set { var found: Set = [] - // Scan top-level `auxiliary..` keys. - for line in viewModel.rawConfigYAML.components(separatedBy: "\n") { - let stripped = line.drop(while: { $0 == " " || $0 == "\t" }) - // YAML emits these as either nested under `auxiliary:` - // or as flat dot-paths depending on Hermes' dump style. - // We match either shape by looking for keys after - // `auxiliary.` OR by walking nested indentation. - // Quickest robust approach: regex over the raw text. - if let m = stripped.range(of: #"^auxiliary\.([A-Za-z0-9_]+)\."#, options: .regularExpression) { - let frag = String(stripped[m]) - let parts = frag.split(separator: ".") - if parts.count >= 2 { - found.insert(String(parts[1])) - } - } - } - // Nested form: scan for `auxiliary:` then collect - // direct child keys. - let lines = viewModel.rawConfigYAML.components(separatedBy: "\n") var inAuxBlock = false var auxIndent = -1 - for line in lines { + // Indent of the first task-name line we see inside the + // block. Established lazily so we work with both 2- and + // 4-space indentation. Once locked, only collect at this + // exact indent — anything deeper is a leaf field. + var taskIndent = -1 + for rawLine in yaml.components(separatedBy: "\n") { + // Strip line-trailing CRs (Windows / SSH artifacts). + let line = rawLine.hasSuffix("\r") ? String(rawLine.dropLast()) : rawLine let trimmed = line.drop(while: { $0 == " " || $0 == "\t" }) let indent = line.count - trimmed.count - if trimmed.hasPrefix("auxiliary:") { - inAuxBlock = true - auxIndent = indent - continue - } - guard inAuxBlock else { continue } - // Out of the aux block when indent drops back to or - // below auxIndent on a non-empty line. - if !trimmed.isEmpty && indent <= auxIndent { - inAuxBlock = false - continue - } - // Direct children of `auxiliary:` are at indent - // > auxIndent and contain a `key:` (no further nesting - // dots before the colon). - if indent > auxIndent, - let colonIdx = trimmed.firstIndex(of: ":") { - let key = trimmed[.. taskIndent. + guard indent == taskIndent else { continue } + // The line should look like `:` or `: `. + // Match `:` at the start to filter out + // things like flow-style maps `[a, b]:` that aren't + // task definitions. + guard let colonIdx = trimmed.firstIndex(of: ":") else { continue } + let key = trimmed[..