Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/__mocks__/get-folder-size.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,10 @@ module.exports = async function getFolderSize() {
errors: [],
}
}

module.exports.loose = async function getFolderSizeLoose() {
return {
size: 1000,
errors: [],
}
}
2 changes: 0 additions & 2 deletions src/core/config/CustomModesManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,6 @@ export class CustomModesManager {
const settings = JSON.parse(content)
const result = CustomModesSettingsSchema.safeParse(settings)
if (!result.success) {
const errorMsg = `Schema validation failed for ${filePath}`
console.error(`[CustomModesManager] ${errorMsg}:`, result.error)
return []
}

Expand Down
29 changes: 13 additions & 16 deletions src/core/config/CustomModesSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,19 @@ const GroupOptionsSchema = z.object({
const GroupEntrySchema = z.union([ToolGroupSchema, z.tuple([ToolGroupSchema, GroupOptionsSchema])])

// Schema for array of groups
const GroupsArraySchema = z
.array(GroupEntrySchema)
.min(1, "At least one tool group is required")
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't necessary

.refine(
(groups) => {
const seen = new Set()
return groups.every((group) => {
// For tuples, check the group name (first element)
const groupName = Array.isArray(group) ? group[0] : group
if (seen.has(groupName)) return false
seen.add(groupName)
return true
})
},
{ message: "Duplicate groups are not allowed" },
)
const GroupsArraySchema = z.array(GroupEntrySchema).refine(
(groups) => {
const seen = new Set()
return groups.every((group) => {
// For tuples, check the group name (first element)
const groupName = Array.isArray(group) ? group[0] : group
if (seen.has(groupName)) return false
seen.add(groupName)
return true
})
},
{ message: "Duplicate groups are not allowed" },
)

// Schema for mode configuration
export const CustomModeSchema = z.object({
Expand Down
22 changes: 0 additions & 22 deletions src/core/config/__tests__/CustomModesSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,17 +95,6 @@ describe("CustomModeSchema", () => {
expect(() => validateCustomMode(invalidGroupMode)).toThrow(ZodError)
})

test("rejects empty groups array", () => {
const invalidMode = {
slug: "123e4567-e89b-12d3-a456-426614174000",
name: "Test Mode",
roleDefinition: "Test role definition",
groups: [] as const,
} satisfies ModeConfig

expect(() => validateCustomMode(invalidMode)).toThrow("At least one tool group is required")
})

test("handles null and undefined gracefully", () => {
expect(() => validateCustomMode(null)).toThrow(ZodError)
expect(() => validateCustomMode(undefined)).toThrow(ZodError)
Expand Down Expand Up @@ -179,16 +168,5 @@ describe("CustomModeSchema", () => {

expect(() => CustomModeSchema.parse(modeWithDuplicates)).toThrow(/Duplicate groups/)
})

it("requires at least one group", () => {
const modeWithNoGroups = {
slug: "test",
name: "Test",
roleDefinition: "Test",
groups: [],
}

expect(() => CustomModeSchema.parse(modeWithNoGroups)).toThrow(/At least one tool group is required/)
})
})
})
9 changes: 0 additions & 9 deletions src/core/config/__tests__/GroupConfigSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,6 @@ describe("GroupConfigSchema", () => {
expect(() => CustomModeSchema.parse(mode)).toThrow()
})

test("rejects empty groups array", () => {
const mode = {
...validBaseMode,
groups: [] as const,
} satisfies ModeConfig

expect(() => CustomModeSchema.parse(mode)).toThrow("At least one tool group is required")
})

test("rejects invalid group names", () => {
const mode = {
...validBaseMode,
Expand Down
46 changes: 25 additions & 21 deletions src/core/webview/__tests__/ClineProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ describe("ClineProvider", () => {
// Mock CustomModesManager
const mockCustomModesManager = {
updateCustomMode: jest.fn().mockResolvedValue(undefined),
getCustomModes: jest.fn().mockResolvedValue({}),
getCustomModes: jest.fn().mockResolvedValue({ customModes: [] }),
dispose: jest.fn(),
}

Expand Down Expand Up @@ -1049,7 +1049,7 @@ describe("ClineProvider", () => {
"900x600", // browserViewportSize
"code", // mode
{}, // customModePrompts
{}, // customModes
{ customModes: [] }, // customModes
undefined, // effectiveInstructions
undefined, // preferredLanguage
true, // diffEnabled
Expand Down Expand Up @@ -1102,7 +1102,7 @@ describe("ClineProvider", () => {
"900x600", // browserViewportSize
"code", // mode
{}, // customModePrompts
{}, // customModes
{ customModes: [] }, // customModes
undefined, // effectiveInstructions
undefined, // preferredLanguage
false, // diffEnabled
Expand Down Expand Up @@ -1220,12 +1220,14 @@ describe("ClineProvider", () => {
provider.customModesManager = {
updateCustomMode: jest.fn().mockResolvedValue(undefined),
getCustomModes: jest.fn().mockResolvedValue({
"test-mode": {
slug: "test-mode",
name: "Test Mode",
roleDefinition: "Updated role definition",
groups: ["read"] as const,
},
customModes: [
{
slug: "test-mode",
name: "Test Mode",
roleDefinition: "Updated role definition",
groups: ["read"] as const,
},
],
}),
dispose: jest.fn(),
} as any
Expand All @@ -1251,27 +1253,29 @@ describe("ClineProvider", () => {
)

// Verify state was updated
expect(mockContext.globalState.update).toHaveBeenCalledWith(
"customModes",
expect.objectContaining({
"test-mode": expect.objectContaining({
expect(mockContext.globalState.update).toHaveBeenCalledWith("customModes", {
customModes: [
expect.objectContaining({
slug: "test-mode",
roleDefinition: "Updated role definition",
}),
}),
)
],
})

// Verify state was posted to webview
// Verify state was posted to webview with correct format
expect(mockPostMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "state",
state: expect.objectContaining({
customModes: expect.objectContaining({
"test-mode": expect.objectContaining({
slug: "test-mode",
roleDefinition: "Updated role definition",
}),
}),
customModes: {
customModes: [
expect.objectContaining({
slug: "test-mode",
roleDefinition: "Updated role definition",
}),
],
},
}),
}),
)
Expand Down
99 changes: 79 additions & 20 deletions webview-ui/src/components/prompts/PromptsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
ModeConfig,
GroupEntry,
} from "../../../../src/shared/modes"
import { CustomModeSchema } from "../../../../src/core/config/CustomModesSchema"
import {
supportPrompt,
SupportPromptType,
Expand Down Expand Up @@ -157,15 +158,34 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
const [newModeGroups, setNewModeGroups] = useState<GroupEntry[]>(availableGroups)
const [newModeSource, setNewModeSource] = useState<ModeSource>("global")

// Field-specific error states
const [nameError, setNameError] = useState<string>("")
const [slugError, setSlugError] = useState<string>("")
const [roleDefinitionError, setRoleDefinitionError] = useState<string>("")
const [groupsError, setGroupsError] = useState<string>("")

// Helper to reset form state
const resetFormState = useCallback(() => {
// Reset form fields
setNewModeName("")
setNewModeSlug("")
setNewModeGroups(availableGroups)
setNewModeRoleDefinition("")
setNewModeCustomInstructions("")
setNewModeSource("global")
// Reset error states
setNameError("")
setSlugError("")
setRoleDefinitionError("")
setGroupsError("")
}, [])

// Reset form fields when dialog opens
useEffect(() => {
if (isCreateModeDialogOpen) {
setNewModeGroups(availableGroups)
setNewModeRoleDefinition("")
setNewModeCustomInstructions("")
setNewModeSource("global")
resetFormState()
}
}, [isCreateModeDialogOpen])
}, [isCreateModeDialogOpen, resetFormState])

// Helper function to generate a unique slug from a name
const generateSlug = useCallback((name: string, attempt = 0): string => {
Expand All @@ -186,26 +206,52 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
)

const handleCreateMode = useCallback(() => {
if (!newModeName.trim() || !newModeSlug.trim()) return
// Clear previous errors
setNameError("")
setSlugError("")
setRoleDefinitionError("")
setGroupsError("")

const source = newModeSource
const newMode: ModeConfig = {
slug: newModeSlug,
name: newModeName,
roleDefinition: newModeRoleDefinition.trim() || "",
roleDefinition: newModeRoleDefinition.trim(),
customInstructions: newModeCustomInstructions.trim() || undefined,
groups: newModeGroups,
source,
}

// Validate the mode against the schema
const result = CustomModeSchema.safeParse(newMode)
if (!result.success) {
// Map Zod errors to specific fields
result.error.errors.forEach((error) => {
const field = error.path[0] as string
const message = error.message

switch (field) {
case "name":
setNameError(message)
break
case "slug":
setSlugError(message)
break
case "roleDefinition":
setRoleDefinitionError(message)
break
case "groups":
setGroupsError(message)
break
}
})
return
}

updateCustomMode(newModeSlug, newMode)
switchMode(newModeSlug)
setIsCreateModeDialogOpen(false)
setNewModeName("")
setNewModeSlug("")
setNewModeRoleDefinition("")
setNewModeCustomInstructions("")
setNewModeGroups(availableGroups)
setNewModeSource("global")
resetFormState()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
newModeName,
Expand Down Expand Up @@ -431,7 +477,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {

<div className="mt-5">
<div onClick={(e) => e.stopPropagation()} className="flex justify-between items-center mb-3">
<h3 className="text-vscode-foreground m-0">Mode-Specific Prompts</h3>
<h3 className="text-vscode-foreground m-0">Modes</h3>
<div className="flex gap-2">
<VSCodeButton appearance="icon" onClick={openCreateModeDialog} title="Create new mode">
<span className="codicon codicon-add"></span>
Expand Down Expand Up @@ -727,7 +773,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
alignItems: "center",
marginBottom: "4px",
}}>
<div style={{ fontWeight: "bold" }}>Mode-specific Custom Instructions</div>
<div style={{ fontWeight: "bold" }}>Mode-specific Custom Instructions (optional)</div>
{!findModeBySlug(mode, customModes) && (
<VSCodeButton
appearance="icon"
Expand Down Expand Up @@ -1069,6 +1115,9 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
}}
style={{ width: "100%" }}
/>
{nameError && (
<div className="text-xs text-vscode-errorForeground mt-1">{nameError}</div>
)}
</div>
<div style={{ marginBottom: "16px" }}>
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Slug</div>
Expand All @@ -1091,6 +1140,9 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
The slug is used in URLs and file names. It should be lowercase and contain only
letters, numbers, and hyphens.
</div>
{slugError && (
<div className="text-xs text-vscode-errorForeground mt-1">{slugError}</div>
)}
</div>
<div style={{ marginBottom: "16px" }}>
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Save Location</div>
Expand Down Expand Up @@ -1147,6 +1199,11 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
resize="vertical"
style={{ width: "100%" }}
/>
{roleDefinitionError && (
<div className="text-xs text-vscode-errorForeground mt-1">
{roleDefinitionError}
</div>
)}
</div>
<div style={{ marginBottom: "16px" }}>
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Available Tools</div>
Expand Down Expand Up @@ -1184,9 +1241,14 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
</VSCodeCheckbox>
))}
</div>
{groupsError && (
<div className="text-xs text-vscode-errorForeground mt-1">{groupsError}</div>
)}
</div>
<div style={{ marginBottom: "16px" }}>
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>Custom Instructions</div>
<div style={{ fontWeight: "bold", marginBottom: "4px" }}>
Custom Instructions (optional)
</div>
<div
style={{
fontSize: "13px",
Expand Down Expand Up @@ -1219,10 +1281,7 @@ const PromptsView = ({ onDone }: PromptsViewProps) => {
backgroundColor: "var(--vscode-editor-background)",
}}>
<VSCodeButton onClick={() => setIsCreateModeDialogOpen(false)}>Cancel</VSCodeButton>
<VSCodeButton
appearance="primary"
onClick={handleCreateMode}
disabled={!newModeName.trim() || !newModeSlug.trim()}>
<VSCodeButton appearance="primary" onClick={handleCreateMode}>
Create Mode
</VSCodeButton>
</div>
Expand Down
1 change: 1 addition & 0 deletions webview-ui/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
--color-vscode-notifications-background: var(--vscode-notifications-background);
--color-vscode-notifications-border: var(--vscode-notifications-border);
--color-vscode-descriptionForeground: var(--vscode-descriptionForeground);
--color-vscode-errorForeground: var(--vscode-errorForeground);
}

@layer base {
Expand Down
Loading