2 Commits
Author SHA1 Message Date
rune 922fe05954 Merge pull request '2.5.1' (#11) from 2.5.1 into main
Reviewed-on: #11
2026-08-04 14:12:16 +02:00
rune 162ce066d5 Merge pull request '2.5.0' (#10) from 2.5.0 into main
Reviewed-on: #10
2026-08-03 08:33:23 +02:00
15 changed files with 174 additions and 6046 deletions
+2 -2
View File
@@ -388,7 +388,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.5.2;
MARKETING_VERSION = 2.5.1;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.Confab;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
@@ -440,7 +440,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 2.5.2;
MARKETING_VERSION = 2.5.1;
PRODUCT_BUNDLE_IDENTIFIER = com.oai.Confab;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
+92 -4742
View File
File diff suppressed because it is too large Load Diff
@@ -1838,7 +1838,7 @@ Whenever the user asks you to translate something, translate it to Norwegian Bok
</main>
<footer>
<p>&copy; 2025 - <span id="year"></span> <script>document.getElementById('year').textContent = new Date().getFullYear();</script> Confab - Rune Olsen. For support or feedback, visit <a href="https://gitlab.pm/rune/oai-swift">gitlab.pm</a> or <a href="https://confab.no/#contact">Contact Us</a>.</p>
<p>© 2026 Confab - Rune Olsen. For support or feedback, visit <a href="https://gitlab.pm/rune/oai-swift">gitlab.pm</a> or <a href="mailto:support@fubar.pm?subject=Confab Support&body=What can I help you with?">Contact Us</a>.</p>
</footer>
</div>
-247
View File
@@ -1,247 +0,0 @@
//
// CLIServerService.swift
// Confab
//
// Local Unix-socket server for one-shot shell/CLI access to a single fixed model
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use including selling Confab or any part of
// it, standalone or bundled into another product or service
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.
import Foundation
import Network
import os
/// Local Unix-domain-socket server exposing one-shot, non-streaming text completions from a
/// single fixed model (Settings MCP CLI Access) e.g. an `ai "prompt"` shell function.
/// Deliberately minimal for v1: no tool-calling, no streaming, no per-request model override.
/// Speaks a minimal HTTP/1.1 subset (just enough for `curl --unix-socket`) rather than a custom
/// protocol, so it stays curl-friendly and trivially extensible new fields can be added to the
/// JSON request/response bodies later without changing the transport.
final class CLIServerService {
static let shared = CLIServerService()
private init() {}
private let log = Logger(subsystem: Log.subsystem, category: "cli")
/// All listener/connection callbacks run on this single serial queue, so `activeConnections`
/// never needs a lock Network.framework callbacks don't run on the main thread.
private let queue = DispatchQueue(label: "com.oai.Confab.cliserver")
private var listener: NWListener?
private var activeConnections: [ObjectIdentifier: NWConnection] = [:]
static let socketPath: String = {
(("~/Library/Application Support/oAI" as NSString).expandingTildeInPath as NSString)
.appendingPathComponent("cli.sock")
}()
// MARK: - Lifecycle
func start() {
guard SettingsService.shared.cliServerEnabled else {
log.info("CLI server disabled")
return
}
startListening()
}
func stop() {
listener?.cancel()
listener = nil
for (_, connection) in activeConnections { connection.cancel() }
activeConnections.removeAll()
try? FileManager.default.removeItem(atPath: Self.socketPath)
log.info("CLI server stopped")
}
/// Call after toggling cliServerEnabled or changing the provider/model, so the change takes
/// effect immediately instead of requiring an app relaunch.
func restart() {
stop()
start()
}
private func startListening() {
// Remove a stale socket file left behind by an unclean shutdown (bind() fails on an
// existing path even if nothing is listening on it anymore).
try? FileManager.default.removeItem(atPath: Self.socketPath)
let params = NWParameters()
params.defaultProtocolStack.transportProtocol = NWProtocolTCP.Options()
params.requiredLocalEndpoint = NWEndpoint.unix(path: Self.socketPath)
params.allowLocalEndpointReuse = true
do {
let newListener = try NWListener(using: params)
newListener.newConnectionHandler = { [weak self] connection in
self?.handle(connection: connection)
}
newListener.stateUpdateHandler = { [weak self] state in
switch state {
case .ready:
self?.log.info("CLI server listening at \(Self.socketPath)")
case .failed(let error):
self?.log.error("CLI server listener failed: \(error.localizedDescription)")
default:
break
}
}
newListener.start(queue: queue)
listener = newListener
} catch {
log.error("Failed to start CLI server: \(error.localizedDescription)")
}
}
// MARK: - Connection handling
private func handle(connection: NWConnection) {
let id = ObjectIdentifier(connection)
activeConnections[id] = connection
connection.stateUpdateHandler = { [weak self] state in
switch state {
case .failed, .cancelled:
self?.activeConnections.removeValue(forKey: id)
default:
break
}
}
connection.start(queue: queue)
readRequest(on: connection, buffer: Data())
}
private func readRequest(on connection: NWConnection, buffer: Data) {
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
guard let self else { return }
var buffer = buffer
if let data { buffer.append(data) }
if let body = Self.parseRequestBody(from: buffer) {
Task {
let responseData = await self.processRequest(body)
self.sendAndClose(responseData, on: connection)
}
return
}
if isComplete || error != nil {
connection.cancel()
return
}
self.readRequest(on: connection, buffer: buffer)
}
}
private func sendAndClose(_ data: Data, on connection: NWConnection) {
let id = ObjectIdentifier(connection)
connection.send(content: data, completion: .contentProcessed { [weak self] _ in
connection.cancel()
self?.activeConnections.removeValue(forKey: id)
})
}
// MARK: - Request processing
private struct AskRequest: Decodable {
let prompt: String
}
private func processRequest(_ body: Data) async -> Data {
guard let req = try? JSONDecoder().decode(AskRequest.self, from: body),
!req.prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return Self.errorResponse("Request body must be JSON: {\"prompt\": \"...\"}")
}
let settings = SettingsService.shared
guard settings.cliServerEnabled else {
return Self.errorResponse("CLI server is disabled in Settings")
}
guard let providerType = Settings.Provider(rawValue: settings.cliServerProvider),
let provider = ProviderRegistry.shared.getProvider(for: providerType) else {
return Self.errorResponse("No provider configured — set one in Settings > MCP > CLI Access")
}
guard !settings.cliServerModel.isEmpty else {
return Self.errorResponse("No model configured — set one in Settings > MCP > CLI Access")
}
do {
let request = ChatRequest(
messages: [Message(role: .user, content: req.prompt)],
model: settings.cliServerModel,
stream: false
)
let response = try await provider.chat(request: request)
return Self.successResponse(response.content)
} catch {
log.error("CLI request failed: \(error.localizedDescription)")
return Self.errorResponse(error.localizedDescription, statusCode: 500)
}
}
// MARK: - Pure helpers (unit tested see CLIServerServiceTests.swift)
struct AskResponseBody: Codable {
let response: String?
let error: String?
}
/// Parses a minimal HTTP/1.1 request out of `buffer`, returning the body once the full
/// header block and (per Content-Length) body have arrived, or nil if more data is needed.
/// Not a general-purpose HTTP parser this server only ever talks to `curl --unix-socket`,
/// so the method/path/most headers are read past rather than validated.
static func parseRequestBody(from buffer: Data) -> Data? {
let headerTerminator = Data("\r\n\r\n".utf8)
guard let headerEndRange = buffer.range(of: headerTerminator) else { return nil }
let headerData = buffer.subdata(in: buffer.startIndex..<headerEndRange.lowerBound)
guard let headerString = String(data: headerData, encoding: .utf8) else { return nil }
var contentLength = 0
for line in headerString.split(separator: "\r\n") {
let parts = line.split(separator: ":", maxSplits: 1)
guard parts.count == 2,
parts[0].trimmingCharacters(in: .whitespaces).caseInsensitiveCompare("Content-Length") == .orderedSame,
let value = Int(parts[1].trimmingCharacters(in: .whitespaces)) else { continue }
contentLength = value
}
let bodyStart = headerEndRange.upperBound
let availableBodyLength = buffer.distance(from: bodyStart, to: buffer.endIndex)
guard availableBodyLength >= contentLength else { return nil }
let bodyEnd = buffer.index(bodyStart, offsetBy: contentLength)
return buffer.subdata(in: bodyStart..<bodyEnd)
}
static func makeHTTPResponse(statusCode: Int, statusText: String, jsonBody: Data) -> Data {
let header = "HTTP/1.1 \(statusCode) \(statusText)\r\nContent-Type: application/json\r\nContent-Length: \(jsonBody.count)\r\nConnection: close\r\n\r\n"
var data = Data(header.utf8)
data.append(jsonBody)
return data
}
static func successResponse(_ text: String) -> Data {
let body = (try? JSONEncoder().encode(AskResponseBody(response: text, error: nil))) ?? Data()
return makeHTTPResponse(statusCode: 200, statusText: "OK", jsonBody: body)
}
static func errorResponse(_ message: String, statusCode: Int = 400) -> Data {
let body = (try? JSONEncoder().encode(AskResponseBody(response: nil, error: message))) ?? Data()
let statusText = statusCode == 400 ? "Bad Request" : "Internal Server Error"
return makeHTTPResponse(statusCode: statusCode, statusText: statusText, jsonBody: body)
}
}
-30
View File
@@ -1043,36 +1043,6 @@ class SettingsService {
}
}
// MARK: - CLI Server
/// Local Unix-socket server (CLIServerService) for one-shot, non-streaming shell access to a
/// single fixed model deliberately separate from the chat UI's defaultProvider/defaultModel
/// so switching models in the GUI never changes shell-tool behavior, same reasoning as the
/// email handler's own dedicated provider/model pair above.
var cliServerEnabled: Bool {
get { cache["cliServerEnabled"] == "true" }
set {
cache["cliServerEnabled"] = String(newValue)
DatabaseService.shared.setSetting(key: "cliServerEnabled", value: String(newValue))
}
}
var cliServerProvider: String {
get { cache["cliServerProvider"] ?? "openrouter" }
set {
cache["cliServerProvider"] = newValue
DatabaseService.shared.setSetting(key: "cliServerProvider", value: newValue)
}
}
var cliServerModel: String {
get { cache["cliServerModel"] ?? "" }
set {
cache["cliServerModel"] = newValue
DatabaseService.shared.setSetting(key: "cliServerModel", value: newValue)
}
}
var emailSubjectIdentifier: String {
get { cache["emailSubjectIdentifier"] ?? "[OAIBOT]" }
set {
+5 -620
View File
@@ -23,44 +23,14 @@
import Foundation
/// Marked `nonisolated` at the type level (not per-member) since this project builds with
/// `-default-isolation=MainActor` without it, every member (including the static `let` verb
/// arrays) defaults to MainActor-isolated, which then conflicts with `nonisolated` funcs trying
/// to reference them. See CLAUDE.md's Swift 6 Compatibility section / MEMORY.md's Common Gotchas.
nonisolated struct ThinkingVerbs {
/// Get a random thinking verb with ellipsis, in the app's active display language.
/// Each language has its own hand-written set (not a translation of the English one)
/// literal translations of English wordplay ("Waking up the hamsters") often land flat
/// or sound plain odd in another language, so every list was written to be funny/natural
/// on its own terms while covering the same rough categories (classic, technical, mystical,
/// quirky, etc).
struct ThinkingVerbs {
/// Get a random thinking verb with ellipsis
static func random() -> String {
verbs(for: currentLanguageCode).randomElement()! + "..."
verbs.randomElement()! + "..."
}
/// Mirrors how Text()/String(localized:) resolve the active language: the first of the
/// user's preferred languages that this bundle actually ships a localization for (falls
/// back to English otherwise). See CLAUDE.md's supported-language list (en, nb, sv, da, de, fr).
private static var currentLanguageCode: String {
Bundle.main.preferredLocalizations.first ?? "en"
}
/// Internal (not private) so tests can verify the languagelist mapping without depending
/// on Bundle.main's runtime localization state.
static func verbs(for languageCode: String) -> [String] {
switch languageCode {
case "nb": return nbVerbs
case "sv": return svVerbs
case "da": return daVerbs
case "de": return deVerbs
case "fr": return frVerbs
default: return enVerbs
}
}
// MARK: - English
private static let enVerbs = [
/// Collection of fun thinking verbs and phrases
private static let verbs = [
// Classic thinking
"Thinking",
"Pondering",
@@ -174,589 +144,4 @@ nonisolated struct ThinkingVerbs {
"Loading brilliance",
"Unfurling wisdom"
]
// MARK: - Norwegian Bokmål
private static let nbVerbs = [
// Classic thinking
"Tenker",
"Grunner",
"Fundere",
"Reflekterer",
"Mediterer",
"Spekulerer",
"Overveier",
// Fancy/sophisticated
"Kontemplerer",
"Filosoferer",
"Resonnerer",
"Analyserer dypt",
"Bryner hjernen",
// Technical/AI themed
"Beregner",
"Prosesserer",
"Analyserer",
"Syntetiserer",
"Kalkulerer",
"Utleder",
"Kompilerer tanker",
"Kjører algoritmer",
"Knuser data",
"Parser nevroner",
// Creative/playful
"Dagdrømmer",
"Idémyldrer",
"Koker sammen tanker",
"Rører i tankegryta",
"Lar det godgjøre seg",
"Spinner nevroner",
"Varmer opp knollen",
"Klekker ut idéer",
// Mystical/fun
"Rådfører orakelet",
"Leser kaffegrut",
"Maner frem visdom",
"Trollbinder et svar",
"Kaster nevrale garn",
"Spår svaret",
// Quirky/silly
"Gjør greia",
"Trylle frem et svar",
"Aktiverer hjerneceller",
"Bøyer nevronene",
"Varmer opp transistorene",
"Rever opp synapsene",
"Kiler hjernebarken",
"Vekker hamsterne",
"Rådfører tomrommet",
"Spør den magiske 8-ballen",
// Self-aware/meta
"Later som om jeg tenker",
"Ser opptatt ut",
"Trekker ut tiden",
"Teller sauer",
"Tvinner tomlene",
"Ordner tankene",
"Leter etter riktige ord",
// Speed variations
"Tenker fort",
"Lynraskt tankearbeid",
"Dyptenkende",
"Hypertenker",
// Action-oriented
"Snekrer sammen et svar",
"Vever ord",
"Setter sammen tanker",
"Konstruerer svar",
"Formulerer idéer",
"Dirigerer nevroner",
"Koreograferer bits",
// Whimsical
"Får en åpenbaring",
"Kobler prikkene",
"Følger tråden",
"Jager tanker",
"Gjeter idéer",
"Reder ut nevronfloken",
// Time-based
"Tar en tenkepause",
"Puster rolig",
"Tar fem",
"Samler tankene",
"Henter pusten",
// Just plain weird
"Piper og beregner",
"Aktiverer hjernemodus",
"Slår på smartheten",
"Laster ned tanker",
"Bufrer intelligens",
"Laster inn genialitet",
"Folder ut visdom",
"Roter i idébanken",
"Sorterer tankene",
"Venter på et lyn av genialitet",
"Plager hjernen med det",
"Kokende av idéer",
"Grubler høyt",
"Filosoferer over saken"
]
// MARK: - Swedish
private static let svVerbs = [
// Classic thinking
"Tänker",
"Funderar",
"Grubblar",
"Reflekterar",
"Mediterar",
"Spekulerar",
"Överväger",
// Fancy/sophisticated
"Kontemplerar",
"Filosoferar",
"Resonerar",
"Djupanalyserar",
"Vässar hjärnan",
// Technical/AI themed
"Beräknar",
"Processar",
"Analyserar",
"Syntetiserar",
"Kalkylerar",
"Härleder",
"Kompilerar tankar",
"Kör algoritmer",
"Mosar data",
"Parsar nervceller",
// Creative/playful
"Dagdrömmer",
"Idékläcker",
"Kokar ihop tankar",
"Rör i tankegrytan",
"Låter det mogna",
"Snurrar nervceller",
"Värmer upp knoppen",
"Ruvar på idéer",
// Mystical/fun
"Rådfrågar oraklet",
"Läser i kaffesumpen",
"Frammanar visdom",
"Trollbinder ett svar",
"Kastar neurala nät",
"Spår svaret",
// Quirky/silly
"Gör grejen",
"Trollar fram ett svar",
"Aktiverar hjärnceller",
"Böjer nervcellerna",
"Värmer upp transistorerna",
"Varvar upp synapserna",
"Kittlar hjärnbarken",
"Väcker hamstrarna",
"Rådfrågar tomrummet",
"Frågar magiska åttan",
// Self-aware/meta
"Låtsas tänka",
"Ser upptagen ut",
"Drar ut på tiden",
"Räknar får",
"Tvinnar tummarna",
"Ordnar tankarna",
"Letar rätt ord",
// Speed variations
"Tänker snabbt",
"Blixtsnabb tankeverksamhet",
"Djuptänkande",
"Hypertänker",
// Action-oriented
"Snickrar ihop ett svar",
"Väver ord",
"Sätter ihop tankar",
"Konstruerar svar",
"Formulerar idéer",
"Dirigerar nervceller",
"Koreograferar bitar",
// Whimsical
"Får en aha-upplevelse",
"Kopplar ihop punkterna",
"Följer tråden",
"Jagar tankar",
"Vallar idéer",
"Reder ut nervtrasslet",
// Time-based
"Tar en tankepaus",
"Andas lugnt",
"Tar fem",
"Samlar tankarna",
"Hämtar andan",
// Just plain weird
"Piper och beräknar",
"Aktiverar hjärnläge",
"Slår på smartheten",
"Laddar ner tankar",
"Buffrar intelligens",
"Laddar in briljans",
"Vecklar ut visdom",
"Rotar i idébanken",
"Sorterar tankarna",
"Väntar på ett geniblixt",
"Bryr hjärnan med det",
"Kokar av idéer",
"Grubblar högt",
"Filosoferar över saken"
]
// MARK: - Danish
private static let daVerbs = [
// Classic thinking
"Tænker",
"Grunder",
"Grubler",
"Reflekterer",
"Mediterer",
"Spekulerer",
"Overvejer",
// Fancy/sophisticated
"Kontemplerer",
"Filosoferer",
"Ræsonnerer",
"Dybdeanalyserer",
"Skærper hjernen",
// Technical/AI themed
"Beregner",
"Processerer",
"Analyserer",
"Syntetiserer",
"Kalkulerer",
"Udleder",
"Kompilerer tanker",
"Kører algoritmer",
"Knuser data",
"Parser neuroner",
// Creative/playful
"Dagdrømmer",
"Idémylrer",
"Koger tanker sammen",
"Rører i tankegryden",
"Lader det simre",
"Snurrer neuroner",
"Varmer knolden op",
"Udruger idéer",
// Mystical/fun
"Rådspørger orakelet",
"Læser kaffegrums",
"Fremmaner visdom",
"Tryller et svar frem",
"Kaster neurale net",
"Spår svaret",
// Quirky/silly
"Gør tingen",
"Trylle-fremkalder et svar",
"Aktiverer hjerneceller",
"Bøjer neuronerne",
"Varmer transistorerne op",
"Ruller synapserne op",
"Kilder hjernebarken",
"Vækker hamsterne",
"Rådspørger tomrummet",
"Spørger den magiske 8-tal",
// Self-aware/meta
"Lader som om jeg tænker",
"Ser optaget ud",
"Trækker tiden ud",
"Tæller får",
"Snor tommelfingrene",
"Ordner tankerne",
"Leder efter de rette ord",
// Speed variations
"Tænker hurtigt",
"Lynhurtig tænkning",
"Dybttænkende",
"Hypertænker",
// Action-oriented
"Snedkererer et svar",
"Væver ord",
"Samler tanker",
"Konstruerer svar",
"Formulerer idéer",
"Dirigerer neuroner",
"Koreograferer bits",
// Whimsical
"Får en åbenbaring",
"Forbinder prikkerne",
"Følger tråden",
"Jagter tanker",
"Vogter idéer",
"Reder neurontrådene ud",
// Time-based
"Tager en tænkepause",
"Trækker vejret roligt",
"Tager fem",
"Samler tankerne",
"Henter vejret",
// Just plain weird
"Bipper og beregner",
"Aktiverer hjernemodus",
"Tænder for kløgt",
"Downloader tanker",
"Bufrer intelligens",
"Indlæser genialitet",
"Folder visdom ud",
"Roder i idébanken",
"Sorterer tankerne",
"Venter på et lyn af genialitet",
"Plager hjernen med det",
"Kogende af idéer",
"Grunder højt",
"Filosoferer over sagen"
]
// MARK: - German
private static let deVerbs = [
// Classic thinking
"Denkt nach",
"Grübelt",
"Sinniert",
"Reflektiert",
"Meditiert",
"Spekuliert",
"Überlegt",
// Fancy/sophisticated
"Kontempliert",
"Philosophiert",
"Räsoniert",
"Analysiert tiefgründig",
"Schärft das Denkvermögen",
// Technical/AI themed
"Berechnet",
"Verarbeitet",
"Analysiert",
"Synthetisiert",
"Kalkuliert",
"Leitet ab",
"Kompiliert Gedanken",
"Lässt Algorithmen laufen",
"Zermalmt Daten",
"Parst Neuronen",
// Creative/playful
"Tagträumt",
"Brainstormt",
"Braut Gedanken zusammen",
"Rührt im Gedankentopf",
"Lässt es köcheln",
"Dreht Neuronen",
"Wärmt die Denkerstube auf",
"Brütet Ideen aus",
// Mystical/fun
"Befragt das Orakel",
"Liest im Kaffeesatz",
"Beschwört Weisheit",
"Zaubert eine Antwort",
"Wirft neuronale Netze aus",
"Weissagt die Antwort",
// Quirky/silly
"Macht sein Ding",
"Zaubert eine Antwort herbei",
"Aktiviert Gehirnzellen",
"Biegt Neuronen",
"Wärmt die Transistoren auf",
"Dreht die Synapsen hoch",
"Kitzelt die Großhirnrinde",
"Weckt die Hamster",
"Befragt die Leere",
"Fragt die magische Kugel",
// Self-aware/meta
"Tut nur so, als würde es denken",
"Sieht beschäftigt aus",
"Zieht Zeit",
"Zählt Schafe",
"Dreht Däumchen",
"Ordnet die Gedanken",
"Sucht die richtigen Worte",
// Speed variations
"Denkt schnell",
"Blitzschnelles Denken",
"Tiefes Nachdenken",
"Hyperdenken",
// Action-oriented
"Zimmert eine Antwort",
"Webt Worte",
"Fügt Gedanken zusammen",
"Konstruiert Antworten",
"Formuliert Ideen",
"Dirigiert Neuronen",
"Choreografiert Bits",
// Whimsical
"Hat eine Eingebung",
"Verbindet die Punkte",
"Folgt dem roten Faden",
"Jagt Gedanken",
"Hütet Ideen",
"Entwirrt die Neuronen",
// Time-based
"Nimmt sich einen Moment",
"Atmet tief durch",
"Macht kurz Pause",
"Sammelt die Gedanken",
"Holt Luft",
// Just plain weird
"Piept und rechnet",
"Aktiviert den Gehirnmodus",
"Schaltet auf Schlaumodus",
"Lädt Gedanken herunter",
"Puffert Intelligenz",
"Lädt Genialität",
"Entfaltet Weisheit",
"Wühlt in der Ideenkiste",
"Sortiert die Gedanken",
"Wartet auf einen Geistesblitz",
"Quält das Gehirn damit",
"Kocht vor Ideen",
"Grübelt laut",
"Philosophiert über die Sache"
]
// MARK: - French
private static let frVerbs = [
// Classic thinking
"Réfléchit",
"Songe",
"Médite",
"Contemple",
"Spécule",
"Délibère",
"Rumine",
// Fancy/sophisticated
"Philosophise",
"Raisonne",
"Cogite",
"Analyse en profondeur",
"Aiguise ses neurones",
// Technical/AI themed
"Calcule",
"Traite",
"Analyse",
"Synthétise",
"Déduit",
"Compile des pensées",
"Fait tourner des algorithmes",
"Broie des données",
"Parse des neurones",
"Chiffre les possibilités",
// Creative/playful
"Rêvasse",
"Brainstorme",
"Mijote des idées",
"Remue la marmite à idées",
"Laisse mijoter",
"Fait tourner ses neurones",
"Chauffe la matière grise",
"Couve une idée",
// Mystical/fun
"Consulte l'oracle",
"Lit dans le marc de café",
"Invoque la sagesse",
"Conjure une réponse",
"Lance des filets neuronaux",
"Prédit la réponse",
// Quirky/silly
"Fait son truc",
"Fait apparaître une réponse par magie",
"Active ses neurones",
"Étire ses neurones",
"Chauffe les transistors",
"Emballe les synapses",
"Chatouille le cortex",
"Réveille les hamsters",
"Consulte le vide",
"Interroge la boule magique",
// Self-aware/meta
"Fait semblant de réfléchir",
"A l'air occupé",
"Fait durer le suspense",
"Compte les moutons",
"Se tourne les pouces",
"Met de l'ordre dans ses pensées",
"Cherche les mots justes",
// Speed variations
"Réfléchit vite",
"Pensée éclair",
"Réflexion profonde",
"Hyper-réflexion",
// Action-oriented
"Bricole une réponse",
"Tisse des mots",
"Assemble des idées",
"Construit une réponse",
"Formule des idées",
"Dirige ses neurones",
"Chorégraphie des bits",
// Whimsical
"A une révélation",
"Relie les points",
"Suit le fil",
"Traque une pensée",
"Rassemble ses idées",
"Démêle ses neurones",
// Time-based
"Prend un instant",
"Respire un grand coup",
"Fait une pause",
"Rassemble ses pensées",
"Reprend son souffle",
// Just plain weird
"Bip bip, ça calcule",
"Active le mode cerveau",
"Passe en mode intelligent",
"Télécharge des pensées",
"Met l'intelligence en cache",
"Charge du génie",
"Déploie sa sagesse",
"Fouille sa boîte à idées",
"Trie ses pensées",
"Attend un éclair de génie",
"Torture son cerveau",
"Bouillonne d'idées",
"Rumine à voix haute",
"Philosophise sur la question"
]
}
-1
View File
@@ -152,5 +152,4 @@ enum Log {
nonisolated static let ui = AppLogger(subsystem: subsystem, category: "ui")
nonisolated static let general = AppLogger(subsystem: subsystem, category: "general")
nonisolated static let extMcp = AppLogger(subsystem: subsystem, category: "ext-mcp")
nonisolated static let cli = AppLogger(subsystem: subsystem, category: "cli")
}
+7 -37
View File
@@ -120,13 +120,6 @@ class ChatViewModel {
var messages: [Message] = []
var inputText: String = ""
var isGenerating: Bool = false
/// Live "what's happening right now" line shown under ProcessingIndicator's thinking verb
/// while a tool-calling loop is running e.g. "🔧 Calling: read_file". Replaced in place each
/// round rather than appending a new chat message, so a long multi-round tool chain doesn't
/// stack up a growing list of rows. Not persisted; nil whenever no tool round is in flight. The
/// full chain is still recorded, just collapsed into one expandable summary message once the
/// loop finishes see generateAIResponseWithTools's use of allToolCallDetails.
var currentToolActivity: String? = nil
var sessionStats = SessionStats()
var selectedModel: ModelInfo?
var currentProvider: Settings.Provider = .openrouter
@@ -1596,10 +1589,6 @@ Don't narrate future actions ("Let me...") - just use the tools.
streamingTask = Task {
let startTime = Date()
var wasCancelled = false
// Accumulates ToolCallDetail entries across every round of this tool-calling loop
// collapsed into a single expandable summary message once the loop exits (success,
// cancellation, or error), instead of one persisted message per round.
var allToolCallDetails: [ToolCallDetail] = []
do {
// Include web_search tool when online mode is on (not needed for OpenRouter it handles search via :online suffix)
let tools = mcp.getToolSchemas(onlineMode: onlineMode && currentProvider != .openrouter)
@@ -1752,16 +1741,15 @@ Don't narrate future actions ("Let me...") - just use the tools.
break
}
// Show what tools the model is calling as a transient status line rather than
// a new chat message see currentToolActivity's doc comment.
// Show what tools the model is calling
let toolNames = toolCalls.map { $0.functionName }.joined(separator: ", ")
currentToolActivity = String(localized: "🔧 Calling: \(toolNames)")
let toolMsgId = showSystemMessage("🔧 Calling: \(toolNames)")
// Initialise detail entries with inputs (results fill in below); appended to
// allToolCallDetails once this round finishes executing.
// Initialise detail entries with inputs (results fill in below)
var toolDetails: [ToolCallDetail] = toolCalls.map { tc in
ToolCallDetail(name: tc.functionName, input: tc.arguments, result: nil)
}
updateToolCallMessage(id: toolMsgId, details: toolDetails)
let usingTextCalls = !textCalls.isEmpty
@@ -1812,9 +1800,9 @@ Don't narrate future actions ("Let me...") - just use the tools.
resultJSON = "{\"error\": \"Failed to serialize result\"}"
}
// Record the result so the collapsed summary message can show it once
// the whole tool-calling loop finishes.
// Update the detail entry with the result so the UI can show it
toolDetails[i].result = resultJSON
updateToolCallMessage(id: toolMsgId, details: toolDetails)
if usingTextCalls {
// Inject results as a user message for text-call models
@@ -1834,8 +1822,6 @@ Don't narrate future actions ("Let me...") - just use the tools.
apiMessages.append(["role": "user", "content": combined])
}
allToolCallDetails.append(contentsOf: toolDetails)
// If this was the last iteration, note it
if iteration == maxIterations - 1 {
hitIterationLimit = true // We're exiting with pending tool calls
@@ -1848,8 +1834,6 @@ Don't narrate future actions ("Let me...") - just use the tools.
wasCancelled = true
}
flushToolCallSummary(allToolCallDetails)
// If we hit the iteration limit or the model returned no text at all, silently
// nudge a follow-up turn instead of showing a placeholder/blank bubble.
let willAutoContinue = (hitIterationLimit || finishedWithEmptyContent) && !wasCancelled
@@ -1907,10 +1891,6 @@ Don't narrate future actions ("Let me...") - just use the tools.
} catch {
let responseTime = Date().timeIntervalSince(startTime)
// Same collapse as the success path any tool rounds that completed before the
// error/cancellation are still worth keeping a record of.
flushToolCallSummary(allToolCallDetails)
// Check if this was a cancellation
let isCancellation = Task.isCancelled || wasCancelled || error is CancellationError
@@ -1955,17 +1935,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
messages[idx].toolCalls = details
}
}
/// Clears the live tool-activity status line and, if any tool calls actually ran, collapses
/// them into a single persisted, expandable summary message used on every exit path of
/// generateAIResponseWithTools's loop (success, cancellation, or error).
private func flushToolCallSummary(_ details: [ToolCallDetail]) {
currentToolActivity = nil
guard !details.isEmpty else { return }
let summaryId = showSystemMessage("🔧 Used ^[\(details.count) tool call](inflect: true)")
updateToolCallMessage(id: summaryId, details: details)
}
// MARK: - Error Helpers
private func friendlyErrorMessage(from error: Error) -> String {
+17 -30
View File
@@ -53,7 +53,7 @@ struct ChatView: View {
// Processing indicator
if viewModel.isGenerating && viewModel.messages.last?.isStreaming != true {
ProcessingIndicator(toolActivity: viewModel.currentToolActivity)
ProcessingIndicator()
.padding(.horizontal)
}
@@ -156,43 +156,30 @@ struct ChatView: View {
}
struct ProcessingIndicator: View {
/// Current tool round's status (e.g. "🔧 Calling: read_file"), replaced in place each round
/// rather than the chat accumulating a new row per round see ChatViewModel.currentToolActivity.
let toolActivity: String?
@State private var animating = false
@State private var thinkingText = ThinkingVerbs.random()
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(thinkingText)
.font(.system(size: 14, weight: .medium))
.foregroundColor(.confabSecondary)
HStack(spacing: 8) {
Text(thinkingText)
.font(.system(size: 14, weight: .medium))
.foregroundColor(.confabSecondary)
HStack(spacing: 4) {
ForEach(0..<3) { index in
Circle()
.fill(Color.confabSecondary)
.frame(width: 6, height: 6)
.scaleEffect(animating ? 1.0 : 0.5)
.animation(
.easeInOut(duration: 0.6)
.repeatForever()
.delay(Double(index) * 0.2),
value: animating
)
}
HStack(spacing: 4) {
ForEach(0..<3) { index in
Circle()
.fill(Color.confabSecondary)
.frame(width: 6, height: 6)
.scaleEffect(animating ? 1.0 : 0.5)
.animation(
.easeInOut(duration: 0.6)
.repeatForever()
.delay(Double(index) * 0.2),
value: animating
)
}
}
if let toolActivity {
Text(toolActivity)
.font(.system(size: 12))
.foregroundColor(.confabSecondary.opacity(0.75))
.transition(.opacity)
}
}
.animation(.easeInOut(duration: 0.15), value: toolActivity)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.confabSecondary.opacity(0.05))
+1 -1
View File
@@ -47,7 +47,7 @@ enum SyncState {
}
}
var tooltipText: LocalizedStringKey {
var tooltipText: String {
switch self {
case .disabled:
return "Auto-sync disabled"
-163
View File
@@ -113,11 +113,6 @@ struct SettingsView: View {
@State private var isTestingEmailConnection = false
@State private var emailConnectionTestResult: String?
// CLI server state
@State private var showCLIModelSelector = false
@State private var cliAvailableModels: [ModelInfo] = []
@State private var isLoadingCLIModels = false
private let labelWidth: CGFloat = 160
// Default system prompt - generic for all models
@@ -916,10 +911,6 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
Divider()
externalMCPSection
// MARK: CLI Access
Divider()
cliServerSection
// MARK: Personal Data
if !PersonalDataTools.isHiddenPendingAppleFix {
Divider()
@@ -1104,160 +1095,6 @@ It's better to admit "I need more information" or "I cannot do that" than to fak
}
}
// MARK: - CLI Access Section
@ViewBuilder
private var cliServerSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "terminal")
.font(.title2)
.foregroundStyle(.green)
Text("CLI Access")
.font(.system(size: 18, weight: .semibold))
}
Text("Expose a local socket so a shell command (like a zsh \"ai\" function) can get a one-shot text reply from a single fixed model, without opening the app window. Confab must be running.")
.font(.system(size: 14))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.bottom, 4)
.onAppear {
Task { await loadCLIModels() }
}
.sheet(isPresented: $showCLIModelSelector) {
ModelSelectorView(
models: cliAvailableModels.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending },
selectedModel: cliAvailableModels.first(where: { $0.id == settingsService.cliServerModel })
) { selectedModel in
settingsService.cliServerModel = selectedModel.id
showCLIModelSelector = false
CLIServerService.shared.restart()
}
}
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Status")
formSection {
row("Enable CLI Access") {
Toggle("", isOn: $settingsService.cliServerEnabled)
.toggleStyle(.switch)
.onChange(of: settingsService.cliServerEnabled) {
CLIServerService.shared.restart()
}
}
}
}
if settingsService.cliServerEnabled {
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Model")
formSection {
row("Provider") {
Picker("", selection: $settingsService.cliServerProvider) {
ForEach(ProviderRegistry.shared.configuredProviders, id: \.self) { provider in
Text(provider.displayName).tag(provider.rawValue)
}
}
.labelsHidden()
.frame(width: 250)
.onChange(of: settingsService.cliServerProvider) {
Task { await loadCLIModels() }
CLIServerService.shared.restart()
}
}
rowDivider()
row("Model") {
if isLoadingCLIModels {
ProgressView().scaleEffect(0.7).frame(width: 250, alignment: .leading)
} else if cliAvailableModels.isEmpty {
Text("No models available")
.font(.system(size: settingsService.guiTextSize))
.foregroundColor(.secondary)
.frame(width: 250, alignment: .leading)
} else {
Button(action: { showCLIModelSelector = true }) {
HStack {
Text(cliAvailableModels.first(where: { $0.id == settingsService.cliServerModel })?.name ?? "Select model...")
.font(.system(size: settingsService.guiTextSize))
.foregroundColor(.primary)
Spacer()
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 10))
.foregroundColor(.secondary)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.frame(width: 250)
.background(Color.secondary.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
}
}
}
}
VStack(alignment: .leading, spacing: 6) {
sectionHeader("Shell Function")
Text("Add this to your ~/.zshrc, then run `ai \"your prompt\"` in Terminal:")
.font(.system(size: 12))
.foregroundStyle(.secondary)
Text(Self.cliShellFunctionSnippet)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.primary)
.textSelection(.enabled)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.secondary.opacity(0.08))
.clipShape(RoundedRectangle(cornerRadius: 8))
Text("Requires jq (brew install jq).")
.font(.system(size: 11))
.foregroundStyle(.tertiary)
}
}
}
private static let cliShellFunctionSnippet = """
ai() {
curl -s --unix-socket "$HOME/Library/Application Support/oAI/cli.sock" \\
-H "Content-Type: application/json" \\
-d "$(jq -n --arg p "$*" '{prompt: $p}')" \\
http://localhost/ | jq -r 'if .error then "Error: " + .error else .response end'
}
"""
private func loadCLIModels() async {
guard settingsService.cliServerEnabled else {
cliAvailableModels = []
return
}
let providerRawValue = settingsService.cliServerProvider
guard let providerType = Settings.Provider(rawValue: providerRawValue),
let provider = ProviderRegistry.shared.getProvider(for: providerType) else {
cliAvailableModels = []
return
}
isLoadingCLIModels = true
defer { isLoadingCLIModels = false }
do {
let models = try await provider.listModels()
cliAvailableModels = models
if !models.contains(where: { $0.id == settingsService.cliServerModel }) {
if let firstModel = models.first {
settingsService.cliServerModel = firstModel.id
}
}
} catch {
Log.ui.error("Failed to load CLI server models: \(error.localizedDescription)")
cliAvailableModels = []
}
}
@ViewBuilder
private var addExternalMCPServerSheet: some View {
VStack(alignment: .leading, spacing: 20) {
+49 -1
View File
@@ -3,7 +3,7 @@
"strings" : {
"CFBundleName" : {
"comment" : "Bundle name",
"extractionState" : "stale",
"extractionState" : "extracted_with_value",
"localizations" : {
"da" : {
"stringUnit" : {
@@ -36,6 +36,54 @@
}
}
}
},
"NSCalendarsFullAccessUsageDescription" : {
"comment" : "Privacy - Calendars Full Access Usage Description",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Confab can read and create calendar events when you ask it to, if you enable Calendar access in Settings."
}
}
}
},
"NSContactsUsageDescription" : {
"comment" : "Privacy - Contacts Usage Description",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Confab can search your contacts when you ask it to, if you enable Contacts access in Settings."
}
}
}
},
"NSLocationWhenInUseUsageDescription" : {
"comment" : "Privacy - Location When In Use Usage Description",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Confab can use your current location to answer questions, if you enable Location & Maps access in Settings."
}
}
}
},
"NSRemindersFullAccessUsageDescription" : {
"comment" : "Privacy - Reminders Full Access Usage Description",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Confab can read and create reminders when you ask it to, if you enable Reminders access in Settings."
}
}
}
}
},
"version" : "1.1"
-4
View File
@@ -73,9 +73,6 @@ struct oAIApp: App {
// Start email handler on app launch
EmailHandlerService.shared.start()
// Start the local CLI server (Settings > Advanced > CLI Access) no-op if disabled
CLIServerService.shared.start()
// Start external MCP servers
Task { @MainActor in ExternalMCPManager.shared.startAll() }
@@ -109,7 +106,6 @@ struct oAIApp: App {
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
Task { @MainActor in ExternalMCPManager.shared.stopAll() }
CLIServerService.shared.stop()
}
#endif
}
-107
View File
@@ -1,107 +0,0 @@
//
// CLIServerServiceTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import Confab
@Suite("CLIServerService pure HTTP helpers")
struct CLIServerServiceTests {
// MARK: - parseRequestBody
@Test("Returns the body once headers and full Content-Length body have arrived")
func parsesCompleteRequest() {
let body = "{\"prompt\":\"hi\"}"
let raw = "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: \(body.utf8.count)\r\n\r\n\(body)"
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
#expect(result.map { String(data: $0, encoding: .utf8) } == body)
}
@Test("Returns nil when the header block hasn't fully arrived yet")
func returnsNilForIncompleteHeaders() {
let raw = "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5"
#expect(CLIServerService.parseRequestBody(from: Data(raw.utf8)) == nil)
}
@Test("Returns nil when the body hasn't fully arrived yet")
func returnsNilForIncompleteBody() {
let raw = "POST / HTTP/1.1\r\nContent-Length: 20\r\n\r\n{\"prompt\":\"hi\"}"
#expect(CLIServerService.parseRequestBody(from: Data(raw.utf8)) == nil)
}
@Test("Content-Length header name match is case-insensitive")
func headerNameIsCaseInsensitive() {
let body = "abc"
let raw = "POST / HTTP/1.1\r\ncontent-length: 3\r\n\r\n\(body)"
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
#expect(result.map { String(data: $0, encoding: .utf8) } == body)
}
@Test("Missing Content-Length is treated as a zero-length body")
func missingContentLengthIsEmptyBody() {
let raw = "GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
#expect(result?.isEmpty == true)
}
@Test("Extra trailing bytes beyond Content-Length don't prevent extraction (pipelined data ignored)")
func extraTrailingBytesStillExtractsBody() {
let body = "abc"
let raw = "POST / HTTP/1.1\r\nContent-Length: 3\r\n\r\n\(body)EXTRA"
let result = CLIServerService.parseRequestBody(from: Data(raw.utf8))
#expect(result.map { String(data: $0, encoding: .utf8) } == body)
}
// MARK: - Response encoding
@Test("successResponse produces a 200 with the response field set")
func successResponseShape() throws {
let data = CLIServerService.successResponse("hello world")
let text = String(data: data, encoding: .utf8)!
#expect(text.hasPrefix("HTTP/1.1 200 OK\r\n"))
#expect(text.contains("Content-Type: application/json"))
let bodyStart = text.range(of: "\r\n\r\n")!.upperBound
let bodyJSON = Data(text[bodyStart...].utf8)
let decoded = try JSONDecoder().decode(CLIServerService.AskResponseBody.self, from: bodyJSON)
#expect(decoded.response == "hello world")
#expect(decoded.error == nil)
}
@Test("errorResponse defaults to 400 Bad Request")
func errorResponseDefaultStatus() throws {
let data = CLIServerService.errorResponse("bad input")
let text = String(data: data, encoding: .utf8)!
#expect(text.hasPrefix("HTTP/1.1 400 Bad Request\r\n"))
let bodyStart = text.range(of: "\r\n\r\n")!.upperBound
let bodyJSON = Data(text[bodyStart...].utf8)
let decoded = try JSONDecoder().decode(CLIServerService.AskResponseBody.self, from: bodyJSON)
#expect(decoded.error == "bad input")
#expect(decoded.response == nil)
}
@Test("errorResponse supports a custom status code")
func errorResponseCustomStatus() {
let data = CLIServerService.errorResponse("provider failed", statusCode: 500)
let text = String(data: data, encoding: .utf8)!
#expect(text.hasPrefix("HTTP/1.1 500 Internal Server Error\r\n"))
}
@Test("Content-Length in the response header matches the actual JSON body byte count")
func responseContentLengthMatchesBody() {
let data = CLIServerService.successResponse("hello")
let text = String(data: data, encoding: .utf8)!
let headerEnd = text.range(of: "\r\n\r\n")!.upperBound
let bodyByteCount = Data(text[headerEnd...].utf8).count
let lengthLine = text.split(separator: "\r\n").first { $0.hasPrefix("Content-Length:") }!
let declaredLength = Int(lengthLine.split(separator: " ")[1])!
#expect(declaredLength == bodyByteCount)
}
}
-60
View File
@@ -1,60 +0,0 @@
//
// ThinkingVerbsTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import Confab
@Suite("ThinkingVerbs language selection")
struct ThinkingVerbsTests {
private let supportedCodes = ["en", "nb", "sv", "da", "de", "fr"]
@Test("Every supported language has its own non-empty verb list")
func perLanguageListsAreNonEmpty() {
for code in supportedCodes {
#expect(!ThinkingVerbs.verbs(for: code).isEmpty)
}
}
@Test("Unknown language codes fall back to English")
func unknownCodeFallsBackToEnglish() {
#expect(ThinkingVerbs.verbs(for: "xx") == ThinkingVerbs.verbs(for: "en"))
#expect(ThinkingVerbs.verbs(for: "") == ThinkingVerbs.verbs(for: "en"))
}
@Test("Each supported language's list is genuinely distinct, not a fallback copy of English")
func perLanguageListsAreDistinctFromEnglish() {
let english = ThinkingVerbs.verbs(for: "en")
for code in supportedCodes where code != "en" {
#expect(ThinkingVerbs.verbs(for: code) != english)
}
}
@Test("Language lists are comparable in size — none is a token stub")
func perLanguageListsAreComparablyMore() {
let minimumCount = 40
for code in supportedCodes {
#expect(ThinkingVerbs.verbs(for: code).count >= minimumCount)
}
}
@Test("No duplicate entries within a single language's list")
func noDuplicatesWithinLanguage() {
for code in supportedCodes {
let list = ThinkingVerbs.verbs(for: code)
#expect(Set(list).count == list.count)
}
}
@Test("random() returns a non-empty string ending in an ellipsis")
func randomProducesEllipsisSuffixedString() {
let result = ThinkingVerbs.random()
#expect(!result.isEmpty)
#expect(result.hasSuffix("..."))
}
}