refactor: dedupe core lowercase helpers

This commit is contained in:
Peter Steinberger
2026-04-07 20:45:35 +01:00
parent dffa88f396
commit bfff74fb11
19 changed files with 103 additions and 63 deletions

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -231,7 +231,7 @@ function normalizeStringArray<T extends string>(
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<T extends string>(
}
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;

View File

@@ -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";
}

View File

@@ -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;
}

View File

@@ -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";
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<SlashCommand["getArgumentCompletions"]> {
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 })),
},
{

View File

@@ -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;

View File

@@ -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(" ")) };
});
}

View File

@@ -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;

View File

@@ -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");

View File

@@ -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: {