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 @@...@@ -1,6 +1,6 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import { getAppModelOptions, normalizeCustomModelSlugs } from "./appSettings";3import { getAppModelOptions, getSlashModelOptions, normalizeCustomModelSlugs } from "./appSettings";
44
5describe("normalizeCustomModelSlugs", () => {5describe("normalizeCustomModelSlugs", () => {
6 it("normalizes aliases, removes built-ins, and deduplicates values", () => {6 it("normalizes aliases, removes built-ins, and deduplicates values", () => {
...@@ -40,3 +40,17 @@ describe("getAppModelOptions", () => {...@@ -40,3 +40,17 @@ describe("getAppModelOptions", () => {
40 });40 });
41 });41 });
42});42});
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(...@@ -105,6 +105,24 @@ export function getAppModelOptions(
105 return options;105 return options;
106}106}
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
108function emitChange(): void {126function emitChange(): void {
109 for (const listener of listeners) {127 for (const listener of listeners) {
110 listener();128 listener();
apps/web/src/components/ChatView.tsx+7-15
...@@ -118,7 +118,7 @@ import { Toggle } from "./ui/toggle";...@@ -118,7 +118,7 @@ import { Toggle } from "./ui/toggle";
118import { SidebarTrigger } from "./ui/sidebar";118import { SidebarTrigger } from "./ui/sidebar";
119import { newCommandId, newMessageId } from "~/lib/utils";119import { newCommandId, newMessageId } from "~/lib/utils";
120import { readNativeApi } from "~/nativeApi";120import { readNativeApi } from "~/nativeApi";
121import { getAppModelOptions, useAppSettings } from "../appSettings";121import { getAppModelOptions, getSlashModelOptions, useAppSettings } from "../appSettings";
122import {122import {
123 type ComposerImageAttachment,123 type ComposerImageAttachment,
124 type DraftThreadEnvMode,124 type DraftThreadEnvMode,
...@@ -632,6 +632,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -632,6 +632,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
632 () => getAppModelOptions(settings.customCodexModels, selectedModel),632 () => getAppModelOptions(settings.customCodexModels, selectedModel),
633 [selectedModel, settings.customCodexModels],633 [selectedModel, settings.customCodexModels],
634 );634 );
635 const slashModelOptions = useMemo(
636 () => getSlashModelOptions(settings.customCodexModels, composerTrigger?.query ?? "", selectedModel),
637 [composerTrigger?.query, selectedModel, settings.customCodexModels],
638 );
635 const phase = derivePhase(activeThread?.session ?? null);639 const phase = derivePhase(activeThread?.session ?? null);
636 const isSendBusy = sendPhase !== "idle";640 const isSendBusy = sendPhase !== "idle";
637 const isPreparingWorktree = sendPhase === "preparing-worktree";641 const isPreparingWorktree = sendPhase === "preparing-worktree";
...@@ -907,26 +911,14 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -907,26 +911,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
907 ];911 ];
908 }912 }
909913
910 return modelOptions914 return slashModelOptions.map(({ slug, name }) => ({
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 }) => ({
923 id: `model:${slug}`,915 id: `model:${slug}`,
924 type: "model" as const,916 type: "model" as const,
925 model: slug,917 model: slug,
926 label: name,918 label: name,
927 description: slug,919 description: slug,
928 }));920 }));
929 }, [composerTrigger, modelOptions, workspaceEntries]);921 }, [composerTrigger, slashModelOptions, workspaceEntries]);
930 const composerMenuOpen = Boolean(composerTrigger);922 const composerMenuOpen = Boolean(composerTrigger);
931 const activeComposerMenuItem = useMemo(923 const activeComposerMenuItem = useMemo(
932 () =>924 () =>