diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 9f0a4de5cb1..a3312aad513 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { parseStrictInteger, parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; import { cleanStaleGatewayProcessesSync } from "../infra/restart-stale-pids.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { GATEWAY_LAUNCH_AGENT_LABEL, resolveGatewayServiceDescription, @@ -301,7 +302,7 @@ export async function readLaunchAgentRuntime( } const parsed = parseLaunchctlPrint(res.stdout || res.stderr || ""); const plistExists = await launchAgentPlistExists(env); - const state = parsed.state?.toLowerCase(); + const state = normalizeLowercaseStringOrEmpty(parsed.state); const status = state === "running" || parsed.pid ? "running" : state ? "stopped" : "unknown"; return { status, @@ -331,7 +332,7 @@ export async function repairLaunchAgentBootstrap(args: { let repairStatus: LaunchAgentBootstrapRepairResult["status"] = "repaired"; if (boot.code !== 0) { const detail = (boot.stderr || boot.stdout).trim(); - const normalized = detail.toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(detail); const alreadyLoaded = boot.code === 130 || normalized.includes("already exists in domain"); if (!alreadyLoaded) { return { ok: false, status: "bootstrap-failed", detail: detail || undefined }; @@ -447,7 +448,7 @@ export async function uninstallLaunchAgent({ } function isLaunchctlNotLoaded(res: { stdout: string; stderr: string; code: number }): boolean { - const detail = (res.stderr || res.stdout).toLowerCase(); + const detail = normalizeLowercaseStringOrEmpty(res.stderr || res.stdout); return ( detail.includes("no such process") || detail.includes("could not find service") || @@ -456,7 +457,7 @@ function isLaunchctlNotLoaded(res: { stdout: string; stderr: string; code: numbe } function isUnsupportedGuiDomain(detail: string): boolean { - const normalized = detail.toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(detail); return ( normalized.includes("domain does not support specified action") || normalized.includes("bootstrap failed: 125") diff --git a/src/daemon/runtime-binary.ts b/src/daemon/runtime-binary.ts index 794fe872bad..b5ade2570ae 100644 --- a/src/daemon/runtime-binary.ts +++ b/src/daemon/runtime-binary.ts @@ -1,10 +1,12 @@ +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; + const NODE_VERSIONED_PATTERN = /^node(?:-\d+|\d+)(?:\.\d+)*(?:\.exe)?$/; function normalizeRuntimeBasename(execPath: string): string { const trimmed = execPath.trim().replace(/^["']|["']$/g, ""); const lastSlash = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); const basename = lastSlash === -1 ? trimmed : trimmed.slice(lastSlash + 1); - return basename.toLowerCase(); + return normalizeLowercaseStringOrEmpty(basename); } export function isNodeRuntime(execPath: string): boolean { diff --git a/src/daemon/runtime-paths.ts b/src/daemon/runtime-paths.ts index e55e2fe47af..798ceecdca0 100644 --- a/src/daemon/runtime-paths.ts +++ b/src/daemon/runtime-paths.ts @@ -5,6 +5,7 @@ import { promisify } from "node:util"; import { isSupportedNodeVersion } from "../infra/runtime-guard.js"; import { resolveStableNodePath } from "../infra/stable-node-path.js"; import { getWindowsProgramFilesRoots } from "../infra/windows-install-roots.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; const VERSION_MANAGER_MARKERS = [ "/.nvm/", @@ -23,7 +24,7 @@ function getPathModule(platform: NodeJS.Platform) { function isNodeExecPath(execPath: string, platform: NodeJS.Platform): boolean { const pathModule = getPathModule(platform); - const base = pathModule.basename(execPath).toLowerCase(); + const base = normalizeLowercaseStringOrEmpty(pathModule.basename(execPath)); return base === "node" || base === "node.exe"; } @@ -31,7 +32,7 @@ function normalizeForCompare(input: string, platform: NodeJS.Platform): string { const pathModule = getPathModule(platform); const normalized = pathModule.normalize(input).replaceAll("\\", "/"); if (platform === "win32") { - return normalized.toLowerCase(); + return normalizeLowercaseStringOrEmpty(normalized); } return normalized; } diff --git a/src/daemon/service-audit.ts b/src/daemon/service-audit.ts index 4965005eac0..aafa85349e9 100644 --- a/src/daemon/service-audit.ts +++ b/src/daemon/service-audit.ts @@ -1,6 +1,9 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "../shared/string-coerce.js"; import { resolveLaunchAgentPlistPath } from "./launchd.js"; import { isBunRuntime, isNodeRuntime } from "./runtime-binary.js"; import { @@ -252,7 +255,7 @@ function normalizePathEntry(entry: string, platform: NodeJS.Platform): string { const pathModule = getPathModule(platform); const normalized = pathModule.normalize(entry).replaceAll("\\", "/"); if (platform === "win32") { - return normalized.toLowerCase(); + return normalizeLowercaseStringOrEmpty(normalized); } return normalized; } diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index 42cf263797f..f9b052e4c74 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { formatErrorMessage } from "../infra/errors.js"; import { parseStrictInteger, parseStrictPositiveInteger } from "../infra/parse-finite-number.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { splitArgsPreservingQuotes } from "./arg-split.js"; import { LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES, @@ -277,7 +278,7 @@ function isSystemdUnitNotEnabled(detail: string): boolean { if (!detail) { return false; } - const normalized = detail.toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(detail); return ( normalized.includes("disabled") || normalized.includes("static") || @@ -317,7 +318,7 @@ export function isNonFatalSystemdInstallProbeError(error: unknown): boolean { if (!detail) { return false; } - const normalized = detail.toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(detail); return isSystemctlBusUnavailable(normalized) || isGenericSystemctlIsEnabledFailure(normalized); } @@ -628,7 +629,7 @@ export async function readSystemdServiceRuntime( ]); if (res.code !== 0) { const detail = (res.stderr || res.stdout).trim(); - const missing = detail.toLowerCase().includes("not found"); + const missing = normalizeLowercaseStringOrEmpty(detail).includes("not found"); return { status: missing ? "stopped" : "unknown", detail: detail || undefined, @@ -636,7 +637,7 @@ export async function readSystemdServiceRuntime( }; } const parsed = parseSystemdShow(res.stdout || ""); - const activeState = parsed.activeState?.toLowerCase(); + const activeState = normalizeLowercaseStringOrEmpty(parsed.activeState); const status = activeState === "active" ? "running" : activeState ? "stopped" : "unknown"; return { status, @@ -713,4 +714,3 @@ export async function uninstallLegacySystemdUnits({ return units; } -import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; diff --git a/src/logging/subsystem.ts b/src/logging/subsystem.ts index f64b1045c10..c7769c18263 100644 --- a/src/logging/subsystem.ts +++ b/src/logging/subsystem.ts @@ -2,6 +2,7 @@ import { Chalk } from "chalk"; import type { Logger as TsLogger } from "tslog"; import { isVerbose } from "../global-state.js"; import { defaultRuntime, type OutputRuntimeEnv, type RuntimeEnv } from "../runtime.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { clearActiveProgressLine } from "../terminal/progress-line.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import { @@ -73,7 +74,7 @@ function formatRuntimeArg(arg: unknown): string { } function isRichConsoleEnv(): boolean { - const term = (process.env.TERM ?? "").toLowerCase(); + const term = normalizeLowercaseStringOrEmpty(process.env.TERM); if (process.env.COLORTERM || process.env.TERM_PROGRAM) { return true; } @@ -151,7 +152,10 @@ export function stripRedundantSubsystemPrefixForConsole( const closeIdx = message.indexOf("]"); if (closeIdx > 1) { const bracketTag = message.slice(1, closeIdx); - if (bracketTag.toLowerCase() === displaySubsystem.toLowerCase()) { + if ( + normalizeLowercaseStringOrEmpty(bracketTag) === + normalizeLowercaseStringOrEmpty(displaySubsystem) + ) { let i = closeIdx + 1; while (message[i] === " ") { i += 1; @@ -162,7 +166,9 @@ export function stripRedundantSubsystemPrefixForConsole( } const prefix = message.slice(0, displaySubsystem.length); - if (prefix.toLowerCase() !== displaySubsystem.toLowerCase()) { + if ( + normalizeLowercaseStringOrEmpty(prefix) !== normalizeLowercaseStringOrEmpty(displaySubsystem) + ) { return message; } diff --git a/src/mcp/channel-bridge.ts b/src/mcp/channel-bridge.ts index 13da5f16912..621f151a029 100644 --- a/src/mcp/channel-bridge.ts +++ b/src/mcp/channel-bridge.ts @@ -6,6 +6,10 @@ import { GatewayClient } from "../gateway/client.js"; import { APPROVALS_SCOPE, READ_SCOPE, WRITE_SCOPE } from "../gateway/method-scopes.js"; import type { EventFrame } from "../gateway/protocol/index.js"; import { extractFirstTextBlock } from "../shared/chat-message-content.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalLowercaseString, +} from "../shared/string-coerce.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { VERSION } from "../version.js"; import type { @@ -161,12 +165,14 @@ export class OpenClawChannelBridge { includeDerivedTitles: params?.includeDerivedTitles ?? true, includeLastMessage: params?.includeLastMessage ?? true, }); - const requestedChannel = toText(params?.channel)?.toLowerCase(); + const requestedChannel = normalizeOptionalLowercaseString(params?.channel); return (response.sessions ?? []) .map(toConversation) .filter((conversation): conversation is ConversationDescriptor => Boolean(conversation)) .filter((conversation) => - requestedChannel ? conversation.channel.toLowerCase() === requestedChannel : true, + requestedChannel + ? normalizeLowercaseStringOrEmpty(conversation.channel) === requestedChannel + : true, ); } @@ -448,14 +454,16 @@ export class OpenClawChannelBridge { const text = extractFirstTextBlock(payload.message); const permissionMatch = text ? CLAUDE_PERMISSION_REPLY_RE.exec(text) : null; if (permissionMatch) { - const requestId = permissionMatch[2]?.toLowerCase(); + const requestId = normalizeOptionalLowercaseString(permissionMatch[2]); if (requestId && this.pendingClaudePermissions.has(requestId)) { this.pendingClaudePermissions.delete(requestId); await this.sendNotification({ method: "notifications/claude/channel/permission", params: { request_id: requestId, - behavior: permissionMatch[1]?.toLowerCase().startsWith("y") ? "allow" : "deny", + behavior: normalizeLowercaseStringOrEmpty(permissionMatch[1]).startsWith("y") + ? "allow" + : "deny", }, }); return; diff --git a/src/memory-host-sdk/dreaming.ts b/src/memory-host-sdk/dreaming.ts index 95297881e05..be7624d3405 100644 --- a/src/memory-host-sdk/dreaming.ts +++ b/src/memory-host-sdk/dreaming.ts @@ -231,7 +231,7 @@ function normalizeStringArray( const allowedSet = new Set(allowed); const normalized: T[] = []; for (const entry of value) { - const normalizedEntry = normalizeTrimmedString(entry)?.toLowerCase(); + const normalizedEntry = normalizeOptionalLowercaseString(entry); if (!normalizedEntry || !allowedSet.has(normalizedEntry as T)) { continue; } @@ -243,7 +243,7 @@ function normalizeStringArray( } function normalizeStorageMode(value: unknown): MemoryDreamingStorageMode { - const normalized = normalizeTrimmedString(value)?.toLowerCase(); + const normalized = normalizeOptionalLowercaseString(value); if (normalized === "inline" || normalized === "separate" || normalized === "both") { return normalized; } @@ -251,7 +251,7 @@ function normalizeStorageMode(value: unknown): MemoryDreamingStorageMode { } function normalizeSpeed(value: unknown): MemoryDreamingSpeed | undefined { - const normalized = normalizeTrimmedString(value)?.toLowerCase(); + const normalized = normalizeOptionalLowercaseString(value); if (normalized === "fast" || normalized === "balanced" || normalized === "slow") { return normalized; } @@ -259,7 +259,7 @@ function normalizeSpeed(value: unknown): MemoryDreamingSpeed | undefined { } function normalizeThinking(value: unknown): MemoryDreamingThinking | undefined { - const normalized = normalizeTrimmedString(value)?.toLowerCase(); + const normalized = normalizeOptionalLowercaseString(value); if (normalized === "low" || normalized === "medium" || normalized === "high") { return normalized; } @@ -267,7 +267,7 @@ function normalizeThinking(value: unknown): MemoryDreamingThinking | undefined { } function normalizeBudget(value: unknown): MemoryDreamingBudget | undefined { - const normalized = normalizeTrimmedString(value)?.toLowerCase(); + const normalized = normalizeOptionalLowercaseString(value); if (normalized === "cheap" || normalized === "medium" || normalized === "expensive") { return normalized; } @@ -320,7 +320,7 @@ export function resolveMemoryDreamingPluginId( const plugins = asNullableRecord(root?.plugins); const slots = asNullableRecord(plugins?.slots); const configuredSlot = normalizeTrimmedString(slots?.memory); - if (configuredSlot && configuredSlot.toLowerCase() !== "none") { + if (configuredSlot && normalizeLowercaseStringOrEmpty(configuredSlot) !== "none") { return configuredSlot; } return DEFAULT_MEMORY_DREAMING_PLUGIN_ID; diff --git a/src/memory-host-sdk/host/backend-config.ts b/src/memory-host-sdk/host/backend-config.ts index b5a9db26741..1be34cbc6a4 100644 --- a/src/memory-host-sdk/host/backend-config.ts +++ b/src/memory-host-sdk/host/backend-config.ts @@ -13,6 +13,7 @@ import type { MemoryQmdSearchMode, } from "../../config/types.memory.js"; import { normalizeAgentId } from "../../routing/session-key.js"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { resolveUserPath } from "../../utils.js"; import { splitShellArgs } from "../../utils/shell-argv.js"; @@ -107,7 +108,7 @@ const DEFAULT_QMD_SCOPE: SessionSendPolicyConfig = { }; function sanitizeName(input: string): string { - const lower = input.toLowerCase().replace(/[^a-z0-9-]+/g, "-"); + const lower = normalizeLowercaseStringOrEmpty(input).replace(/[^a-z0-9-]+/g, "-"); const trimmed = lower.replace(/^-+|-+$/g, ""); return trimmed || "collection"; } diff --git a/src/memory-host-sdk/host/embedding-model-limits.ts b/src/memory-host-sdk/host/embedding-model-limits.ts index 0819686b905..00766748848 100644 --- a/src/memory-host-sdk/host/embedding-model-limits.ts +++ b/src/memory-host-sdk/host/embedding-model-limits.ts @@ -1,3 +1,4 @@ +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import type { EmbeddingProvider } from "./embeddings.js"; const DEFAULT_EMBEDDING_MAX_INPUT_TOKENS = 8192; @@ -22,7 +23,7 @@ export function resolveEmbeddingMaxInputTokens(provider: EmbeddingProvider): num // Provider/model mapping is best-effort; different providers use different // limits and we prefer to be conservative when we don't know. - const key = `${provider.id}:${provider.model}`.toLowerCase(); + const key = normalizeLowercaseStringOrEmpty(`${provider.id}:${provider.model}`); const known = KNOWN_EMBEDDING_MAX_INPUT_TOKENS[key]; if (typeof known === "number") { return known; @@ -30,10 +31,10 @@ export function resolveEmbeddingMaxInputTokens(provider: EmbeddingProvider): num // Provider-specific conservative fallbacks. This prevents us from accidentally // using the OpenAI default for providers with much smaller limits. - if (provider.id.toLowerCase() === "gemini") { + if (normalizeLowercaseStringOrEmpty(provider.id) === "gemini") { return 2048; } - if (provider.id.toLowerCase() === "local") { + if (normalizeLowercaseStringOrEmpty(provider.id) === "local") { return DEFAULT_LOCAL_EMBEDDING_MAX_INPUT_TOKENS; } diff --git a/src/memory-host-sdk/host/embeddings-bedrock.ts b/src/memory-host-sdk/host/embeddings-bedrock.ts index c536cf22ca5..4c40eb1b218 100644 --- a/src/memory-host-sdk/host/embeddings-bedrock.ts +++ b/src/memory-host-sdk/host/embeddings-bedrock.ts @@ -1,3 +1,4 @@ +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { sanitizeAndNormalizeEmbedding } from "./embedding-vectors.js"; import { debugEmbeddingsLog } from "./embeddings-debug.js"; import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.js"; @@ -73,7 +74,7 @@ function resolveSpec(modelId: string): ModelSpec | undefined { /** Infer family from model ID prefix when not in catalog. */ function inferFamily(modelId: string): Family { - const id = modelId.toLowerCase(); + const id = normalizeLowercaseStringOrEmpty(modelId); if (id.startsWith("amazon.titan-embed-text-v2")) { return "titan-v2"; } diff --git a/src/memory-host-sdk/host/qmd-scope.ts b/src/memory-host-sdk/host/qmd-scope.ts index 7eb522dd55c..3b2a350d2bd 100644 --- a/src/memory-host-sdk/host/qmd-scope.ts +++ b/src/memory-host-sdk/host/qmd-scope.ts @@ -80,7 +80,7 @@ function parseQmdSessionScope(key?: string): ParsedQmdSessionScope { } return { normalizedKey: normalized, - channel: parts[0]?.toLowerCase(), + channel: normalizeOptionalLowercaseString(parts[0]), chatType: chatType ?? "direct", }; } @@ -102,7 +102,7 @@ function normalizeQmdSessionKey(key?: string): string | undefined { return undefined; } const parsed = parseAgentSessionKey(trimmed); - const normalized = (parsed?.rest ?? trimmed).toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(parsed?.rest ?? trimmed); if (normalized.startsWith("subagent:")) { return undefined; } diff --git a/src/node-host/invoke-system-run-plan.ts b/src/node-host/invoke-system-run-plan.ts index 3dde93a6661..abc2ff33f93 100644 --- a/src/node-host/invoke-system-run-plan.ts +++ b/src/node-host/invoke-system-run-plan.ts @@ -19,7 +19,10 @@ import { resolveInlineCommandMatch, } from "../infra/shell-inline-command.js"; import { formatExecCommand, resolveSystemRunCommandRequest } from "../infra/system-run-command.js"; -import { normalizeNullableString } from "../shared/string-coerce.js"; +import { + normalizeLowercaseStringOrEmpty, + normalizeNullableString, +} from "../shared/string-coerce.js"; import { splitShellArgs } from "../utils/shell-argv.js"; export type ApprovedCwdSnapshot = { @@ -138,6 +141,10 @@ const NODE_OPTIONS_WITH_FILE_VALUE = new Set([ const RUBY_UNSAFE_APPROVAL_FLAGS = new Set(["-I", "-r", "--require"]); const PERL_UNSAFE_APPROVAL_FLAGS = new Set(["-I", "-M", "-m"]); +function normalizeOptionFlag(token: string): string { + return normalizeLowercaseStringOrEmpty(token.split("=", 1)[0]); +} + const POSIX_SHELL_OPTIONS_WITH_VALUE = new Set([ "--init-file", "--rcfile", @@ -353,7 +360,7 @@ function unwrapPnpmExecInvocation(argv: string[]): string[] | null { } return null; } - const [flag] = token.toLowerCase().split("=", 2); + const flag = normalizeOptionFlag(token); if (PNPM_OPTIONS_WITH_VALUE.has(flag) || PNPM_DLX_OPTIONS_WITH_VALUE.has(flag)) { idx += token.includes("=") ? 1 : 2; continue; @@ -384,7 +391,7 @@ function unwrapPnpmDlxInvocation(argv: string[]): string[] | null { // package binary pnpm will execute inside the temporary environment. return argv.slice(idx); } - const [flag] = token.toLowerCase().split("=", 2); + const flag = normalizeOptionFlag(token); if (flag === "-c" || flag === "--shell-mode") { return null; } @@ -412,7 +419,7 @@ function unwrapDirectPackageExecInvocation(argv: string[]): string[] | null { if (!token.startsWith("-")) { return argv.slice(idx); } - const [flag] = token.toLowerCase().split("=", 2); + const flag = normalizeOptionFlag(token); if (flag === "-c" || flag === "--call") { return null; } @@ -488,7 +495,7 @@ function resolvePosixShellScriptOperandIndex(argv: string[]): number | null { return null; } if (!afterDoubleDash && token.startsWith("-")) { - const [flag] = token.toLowerCase().split("=", 2); + const flag = normalizeOptionFlag(token); if (POSIX_SHELL_OPTIONS_WITH_VALUE.has(flag)) { if (!token.includes("=")) { i += 1; @@ -595,7 +602,7 @@ function collectExistingFileOperandIndexes(params: { } if (token.startsWith("-")) { const [flag, inlineValue] = token.split("=", 2); - if (params.optionsWithFileValue?.has(flag.toLowerCase())) { + if (params.optionsWithFileValue?.has(normalizeLowercaseStringOrEmpty(flag))) { if (inlineValue && resolvesToExistingFileSync(inlineValue, params.cwd)) { hits.push(i); return { hits, sawOptionValueFile: true }; @@ -697,7 +704,7 @@ function hasRubyUnsafeApprovalFlag(argv: string[]): boolean { if (token.startsWith("-I") || token.startsWith("-r")) { return true; } - if (RUBY_UNSAFE_APPROVAL_FLAGS.has(token.toLowerCase())) { + if (RUBY_UNSAFE_APPROVAL_FLAGS.has(normalizeLowercaseStringOrEmpty(token))) { return true; } } @@ -851,7 +858,7 @@ function pnpmDlxInvocationNeedsFailClosedBinding(argv: string[], cwd: string | u } return pnpmDlxTailNeedsFailClosedBinding(argv.slice(idx + 1), cwd); } - const [flag] = token.toLowerCase().split("=", 2); + const flag = normalizeOptionFlag(token); if (PNPM_OPTIONS_WITH_VALUE.has(flag) || PNPM_DLX_OPTIONS_WITH_VALUE.has(flag)) { idx += token.includes("=") ? 1 : 2; continue; @@ -880,7 +887,7 @@ function pnpmDlxTailNeedsFailClosedBinding(argv: string[], cwd: string | undefin if (!token.startsWith("-")) { return pnpmDlxTailMayNeedStableBinding(argv.slice(idx), cwd); } - const [flag] = token.toLowerCase().split("=", 2); + const flag = normalizeOptionFlag(token); if (flag === "-c" || flag === "--shell-mode") { return false; } diff --git a/src/tui/commands.ts b/src/tui/commands.ts index ae277a23921..21907921c99 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -2,6 +2,7 @@ import type { SlashCommand } from "@mariozechner/pi-tui"; import { listChatCommands, listChatCommandsForConfig } from "../auto-reply/commands-registry.js"; import { formatThinkingLevels, listThinkingLevelLabels } from "../auto-reply/thinking.js"; import type { OpenClawConfig } from "../config/types.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; const VERBOSE_LEVELS = ["on", "off"]; const FAST_LEVELS = ["status", "on", "off"]; @@ -31,7 +32,7 @@ function createLevelCompletion( ): NonNullable { return (prefix) => levels - .filter((value) => value.startsWith(prefix.toLowerCase())) + .filter((value) => value.startsWith(normalizeLowercaseStringOrEmpty(prefix))) .map((value) => ({ value, label: value, @@ -44,7 +45,7 @@ export function parseCommand(input: string): ParsedCommand { return { name: "", args: "" }; } const [name, ...rest] = trimmed.split(/\s+/); - const normalized = name.toLowerCase(); + const normalized = normalizeLowercaseStringOrEmpty(name); return { name: COMMAND_ALIASES[normalized] ?? normalized, args: rest.join(" ").trim(), @@ -77,7 +78,7 @@ export function getSlashCommands(options: SlashCommandOptions = {}): SlashComman description: "Set thinking level", getArgumentCompletions: (prefix) => thinkLevels - .filter((v) => v.startsWith(prefix.toLowerCase())) + .filter((v) => v.startsWith(normalizeLowercaseStringOrEmpty(prefix))) .map((value) => ({ value, label: value })), }, { diff --git a/src/tui/components/filterable-select-list.ts b/src/tui/components/filterable-select-list.ts index 8c8e41672c7..ca39098e790 100644 --- a/src/tui/components/filterable-select-list.ts +++ b/src/tui/components/filterable-select-list.ts @@ -7,6 +7,7 @@ import { type SelectListTheme, } from "@mariozechner/pi-tui"; import chalk from "chalk"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { fuzzyFilterLower, prepareSearchItems } from "./fuzzy-filter.js"; export interface FilterableSelectItem extends SelectItem { @@ -44,7 +45,7 @@ export class FilterableSelectList implements Component { } private applyFilter(): void { - const queryLower = this.filterText.toLowerCase(); + const queryLower = normalizeLowercaseStringOrEmpty(this.filterText); if (!queryLower.trim()) { this.selectList = new SelectList(this.allItems, this.maxVisible, this.theme); return; diff --git a/src/tui/components/fuzzy-filter.ts b/src/tui/components/fuzzy-filter.ts index 7fea774223a..5bb8a793fc7 100644 --- a/src/tui/components/fuzzy-filter.ts +++ b/src/tui/components/fuzzy-filter.ts @@ -2,6 +2,8 @@ * Shared fuzzy filtering utilities for select list components. */ +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; + /** * Word boundary characters for matching. */ @@ -22,8 +24,8 @@ export function findWordBoundaryIndex(text: string, query: string): number | nul if (!query) { return null; } - const textLower = text.toLowerCase(); - const queryLower = query.toLowerCase(); + const textLower = normalizeLowercaseStringOrEmpty(text); + const queryLower = normalizeLowercaseStringOrEmpty(query); const maxIndex = textLower.length - queryLower.length; if (maxIndex < 0) { return null; @@ -133,6 +135,6 @@ export function prepareSearchItems< if (item.searchText) { parts.push(item.searchText); } - return { ...item, searchTextLower: parts.join(" ").toLowerCase() }; + return { ...item, searchTextLower: normalizeLowercaseStringOrEmpty(parts.join(" ")) }; }); } diff --git a/src/tui/components/searchable-select-list.ts b/src/tui/components/searchable-select-list.ts index 43db6d4d990..678efadb318 100644 --- a/src/tui/components/searchable-select-list.ts +++ b/src/tui/components/searchable-select-list.ts @@ -7,6 +7,7 @@ import { type SelectListTheme, truncateToWidth, } from "@mariozechner/pi-tui"; +import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { stripAnsi, visibleWidth } from "../../terminal/ansi.js"; import { findWordBoundaryIndex, fuzzyFilterLower } from "./fuzzy-filter.js"; @@ -80,7 +81,7 @@ export class SearchableSelectList implements Component { * 4. Fuzzy match (lowest priority) */ private smartFilter(query: string): SelectItem[] { - const q = query.toLowerCase(); + const q = normalizeLowercaseStringOrEmpty(query); type ScoredItem = { item: SelectItem; tier: number; score: number }; type FuzzyCandidate = { item: SelectItem; searchTextLower: string }; const scoredItems: ScoredItem[] = []; @@ -89,8 +90,8 @@ export class SearchableSelectList implements Component { for (const item of this.items) { const rawLabel = this.getItemLabel(item); const rawDesc = item.description ?? ""; - const label = stripAnsi(rawLabel).toLowerCase(); - const desc = stripAnsi(rawDesc).toLowerCase(); + const label = normalizeLowercaseStringOrEmpty(stripAnsi(rawLabel)); + const desc = normalizeLowercaseStringOrEmpty(stripAnsi(rawDesc)); // Tier 1: Exact substring in label const labelIndex = label.indexOf(q); @@ -114,11 +115,12 @@ export class SearchableSelectList implements Component { const searchText = (item as { searchText?: string }).searchText ?? ""; fuzzyCandidates.push({ item, - searchTextLower: [rawLabel, rawDesc, searchText] - .map((value) => stripAnsi(value)) - .filter(Boolean) - .join(" ") - .toLowerCase(), + searchTextLower: normalizeLowercaseStringOrEmpty( + [rawLabel, rawDesc, searchText] + .map((value) => stripAnsi(value)) + .filter(Boolean) + .join(" "), + ), }); } @@ -171,7 +173,7 @@ export class SearchableSelectList implements Component { const tokens = query .trim() .split(/\s+/) - .map((token) => token.toLowerCase()) + .map((token) => normalizeLowercaseStringOrEmpty(token)) .filter((token) => token.length > 0); if (tokens.length === 0) { return text; diff --git a/src/tui/tui-submit.ts b/src/tui/tui-submit.ts index 81cb1abe593..f048217102a 100644 --- a/src/tui/tui-submit.ts +++ b/src/tui/tui-submit.ts @@ -1,3 +1,5 @@ +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; + export function createEditorSubmitHandler(params: { editor: { setText: (value: string) => void; @@ -44,7 +46,7 @@ export function shouldEnableWindowsGitBashPasteFallback(params?: { }): boolean { const platform = params?.platform ?? process.platform; const env = params?.env ?? process.env; - const termProgram = (env.TERM_PROGRAM ?? "").toLowerCase(); + const termProgram = normalizeLowercaseStringOrEmpty(env.TERM_PROGRAM); // Some macOS terminals emit multiline paste as rapid single-line submits. // Enable burst coalescing so pasted blocks stay as one user message. @@ -64,7 +66,7 @@ export function shouldEnableWindowsGitBashPasteFallback(params?: { if (msystem.startsWith("MINGW") || msystem.startsWith("MSYS")) { return true; } - if (shell.toLowerCase().includes("bash")) { + if (normalizeLowercaseStringOrEmpty(shell).includes("bash")) { return true; } return termProgram.includes("mintty"); diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 1fa01dcc890..83b063f6d88 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -16,6 +16,7 @@ import { normalizeMainKey, parseAgentSessionKey, } from "../routing/session-key.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { getSlashCommands } from "./commands.js"; import { ChatLog } from "./components/chat-log.js"; import { CustomEditor } from "./components/custom-editor.js"; @@ -69,9 +70,9 @@ export function resolveTuiSessionKey(params: { return trimmed; } if (trimmed.startsWith("agent:")) { - return trimmed.toLowerCase(); + return normalizeLowercaseStringOrEmpty(trimmed); } - return `agent:${params.currentAgentId}:${trimmed.toLowerCase()}`; + return `agent:${params.currentAgentId}:${normalizeLowercaseStringOrEmpty(trimmed)}`; } export function resolveInitialTuiAgentId(params: {