authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-03 09:28:58-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-03 09:31:54-08:00
log09b32ac9dd43baab32b87c91f47f2a1651d1b1e8
tree3a7336a1274931a4c2e18d1bddf2ddf773a33cc1
parent253684e280d5250bef80891552a0ea9704701484

Extract slash model option filtering into shared app settings helper

- add `getSlashModelOptions` to centralize `/model` suggestion filtering - include custom saved model slugs in slash-command suggestions - update ChatView to consume shared helper and add focused tests

3 files changed, 40 insertions(+), 16 deletions(-)

apps/web/src/appSettings.test.ts+15-1
......@@ -1,6 +1,6 @@
11import { describe, expect, it } from "vitest";
22
3import { getAppModelOptions, normalizeCustomModelSlugs } from "./appSettings";
3import { getAppModelOptions, getSlashModelOptions, normalizeCustomModelSlugs } from "./appSettings";
44
55describe("normalizeCustomModelSlugs", () => {
66 it("normalizes aliases, removes built-ins, and deduplicates values", () => {
......@@ -40,3 +40,17 @@ describe("getAppModelOptions", () => {
4040 });
4141 });
4242});
43
44describe("getSlashModelOptions", () => {
45 it("includes saved custom model slugs for /model command suggestions", () => {
46 const options = getSlashModelOptions(["custom/internal-model"], "", "gpt-5.3-codex");
47
48 expect(options.some((option) => option.slug === "custom/internal-model")).toBe(true);
49 });
50
51 it("filters slash-model suggestions across built-in and custom model names", () => {
52 const options = getSlashModelOptions(["openai/gpt-oss-120b"], "oss", "gpt-5.3-codex");
53
54 expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]);
55 });
56});
apps/web/src/appSettings.ts+18
......@@ -105,6 +105,24 @@ export function getAppModelOptions(
105105 return options;
106106}
107107
108export function getSlashModelOptions(
109 customModels: readonly string[],
110 query: string,
111 selectedModel?: string | null,
112): AppModelOption[] {
113 const normalizedQuery = query.trim().toLowerCase();
114 const options = getAppModelOptions(customModels, selectedModel);
115 if (!normalizedQuery) {
116 return options;
117 }
118
119 return options.filter((option) => {
120 const searchSlug = option.slug.toLowerCase();
121 const searchName = option.name.toLowerCase();
122 return searchSlug.includes(normalizedQuery) || searchName.includes(normalizedQuery);
123 });
124}
125
108126function emitChange(): void {
109127 for (const listener of listeners) {
110128 listener();
apps/web/src/components/ChatView.tsx+7-15
......@@ -118,7 +118,7 @@ import { Toggle } from "./ui/toggle";
118118import { SidebarTrigger } from "./ui/sidebar";
119119import { newCommandId, newMessageId } from "~/lib/utils";
120120import { readNativeApi } from "~/nativeApi";
121import { getAppModelOptions, useAppSettings } from "../appSettings";
121import { getAppModelOptions, getSlashModelOptions, useAppSettings } from "../appSettings";
122122import {
123123 type ComposerImageAttachment,
124124 type DraftThreadEnvMode,
......@@ -632,6 +632,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
632632 () => getAppModelOptions(settings.customCodexModels, selectedModel),
633633 [selectedModel, settings.customCodexModels],
634634 );
635 const slashModelOptions = useMemo(
636 () => getSlashModelOptions(settings.customCodexModels, composerTrigger?.query ?? "", selectedModel),
637 [composerTrigger?.query, selectedModel, settings.customCodexModels],
638 );
635639 const phase = derivePhase(activeThread?.session ?? null);
636640 const isSendBusy = sendPhase !== "idle";
637641 const isPreparingWorktree = sendPhase === "preparing-worktree";
......@@ -907,26 +911,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
907911 ];
908912 }
909913
910 return modelOptions
911 .map(({ slug, name }) => ({
912 slug,
913 name,
914 searchSlug: slug.toLowerCase(),
915 searchName: name.toLowerCase(),
916 }))
917 .filter(({ searchSlug, searchName }) => {
918 const query = composerTrigger.query.trim().toLowerCase();
919 if (!query) return true;
920 return searchSlug.includes(query) || searchName.includes(query);
921 })
922 .map(({ slug, name }) => ({
914 return slashModelOptions.map(({ slug, name }) => ({
923915 id: `model:${slug}`,
924916 type: "model" as const,
925917 model: slug,
926918 label: name,
927919 description: slug,
928920 }));
929 }, [composerTrigger, modelOptions, workspaceEntries]);
921 }, [composerTrigger, slashModelOptions, workspaceEntries]);
930922 const composerMenuOpen = Boolean(composerTrigger);
931923 const activeComposerMenuItem = useMemo(
932924 () =>