Skip to content

Commit 953c07a

Browse files
feat(cli): add custom modes support and refactor implementation
feat(cli): add custom modes support and refactor implementation
2 parents f2ff762 + df83fc7 commit 953c07a

File tree

16 files changed

+595
-50
lines changed

16 files changed

+595
-50
lines changed

.changeset/busy-deer-crash.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@kilocode/cli": minor
3+
---
4+
5+
Custom modes support

cli/src/cli.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ export class CLI {
102102
}
103103
}
104104

105+
if (this.options.customModes) {
106+
serviceOptions.customModes = this.options.customModes
107+
}
108+
105109
this.service = createExtensionService(serviceOptions)
106110
logs.debug("ExtensionService created with identity", "CLI", {
107111
hasIdentity: !!identity,

cli/src/commands/__tests__/helpers/mockContext.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export function createMockContext(overrides: Partial<CommandContext> = {}): Comm
5454
balanceData: null,
5555
profileLoading: false,
5656
balanceLoading: false,
57+
customModes: [],
5758
refreshTerminal: vi.fn().mockResolvedValue(undefined),
5859
taskHistoryData: null,
5960
taskHistoryFilters: {
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
/**
2+
* Tests for the /mode command
3+
*/
4+
5+
import { describe, it, expect, beforeEach, vi } from "vitest"
6+
import { modeCommand } from "../mode.js"
7+
import type { CommandContext } from "../core/types.js"
8+
import type { ModeConfig } from "../../types/messages.js"
9+
10+
describe("modeCommand", () => {
11+
let mockContext: CommandContext
12+
let mockAddMessage: ReturnType<typeof vi.fn>
13+
let mockSetMode: ReturnType<typeof vi.fn>
14+
15+
beforeEach(() => {
16+
mockAddMessage = vi.fn()
17+
mockSetMode = vi.fn()
18+
19+
mockContext = {
20+
input: "/mode",
21+
args: [],
22+
options: {},
23+
config: {} as CommandContext["config"],
24+
sendMessage: vi.fn().mockResolvedValue(undefined),
25+
addMessage: mockAddMessage,
26+
clearMessages: vi.fn(),
27+
replaceMessages: vi.fn(),
28+
setMessageCutoffTimestamp: vi.fn(),
29+
clearTask: vi.fn().mockResolvedValue(undefined),
30+
setMode: mockSetMode,
31+
setTheme: vi.fn().mockResolvedValue(undefined),
32+
exit: vi.fn(),
33+
setCommittingParallelMode: vi.fn(),
34+
isParallelMode: false,
35+
routerModels: null,
36+
currentProvider: null,
37+
kilocodeDefaultModel: "",
38+
updateProviderModel: vi.fn().mockResolvedValue(undefined),
39+
refreshRouterModels: vi.fn().mockResolvedValue(undefined),
40+
updateProvider: vi.fn().mockResolvedValue(undefined),
41+
selectProvider: vi.fn().mockResolvedValue(undefined),
42+
profileData: null,
43+
balanceData: null,
44+
profileLoading: false,
45+
balanceLoading: false,
46+
customModes: [],
47+
taskHistoryData: null,
48+
taskHistoryFilters: {
49+
workspace: "current",
50+
sort: "newest",
51+
favoritesOnly: false,
52+
},
53+
taskHistoryLoading: false,
54+
taskHistoryError: null,
55+
fetchTaskHistory: vi.fn().mockResolvedValue(undefined),
56+
updateTaskHistoryFilters: vi.fn().mockResolvedValue(null),
57+
changeTaskHistoryPage: vi.fn().mockResolvedValue(null),
58+
nextTaskHistoryPage: vi.fn().mockResolvedValue(null),
59+
previousTaskHistoryPage: vi.fn().mockResolvedValue(null),
60+
sendWebviewMessage: vi.fn().mockResolvedValue(undefined),
61+
refreshTerminal: vi.fn().mockResolvedValue(undefined),
62+
chatMessages: [],
63+
}
64+
})
65+
66+
describe("command metadata", () => {
67+
it("should have correct name", () => {
68+
expect(modeCommand.name).toBe("mode")
69+
})
70+
71+
it("should have correct aliases", () => {
72+
expect(modeCommand.aliases).toEqual(["m"])
73+
})
74+
75+
it("should have correct description", () => {
76+
expect(modeCommand.description).toBe("Switch to a different mode")
77+
})
78+
79+
it("should have correct usage", () => {
80+
expect(modeCommand.usage).toBe("/mode <mode-name>")
81+
})
82+
83+
it("should have correct category", () => {
84+
expect(modeCommand.category).toBe("settings")
85+
})
86+
87+
it("should have examples", () => {
88+
expect(modeCommand.examples).toEqual(["/mode code", "/mode architect", "/mode debug"])
89+
})
90+
91+
it("should have correct priority", () => {
92+
expect(modeCommand.priority).toBe(9)
93+
})
94+
95+
it("should have arguments defined", () => {
96+
expect(modeCommand.arguments).toBeDefined()
97+
expect(modeCommand.arguments?.length).toBe(1)
98+
expect(modeCommand.arguments?.[0].name).toBe("mode-name")
99+
expect(modeCommand.arguments?.[0].required).toBe(true)
100+
})
101+
})
102+
103+
describe("handler - no arguments", () => {
104+
it("should list available default modes when no arguments provided", async () => {
105+
mockContext.args = []
106+
107+
await modeCommand.handler(mockContext)
108+
109+
expect(mockAddMessage).toHaveBeenCalledTimes(1)
110+
const message = mockAddMessage.mock.calls[0][0]
111+
expect(message.type).toBe("system")
112+
expect(message.content).toContain("**Available Modes:**")
113+
expect(message.content).toContain("architect")
114+
expect(message.content).toContain("code")
115+
expect(message.content).toContain("ask")
116+
expect(message.content).toContain("debug")
117+
expect(message.content).toContain("orchestrator")
118+
})
119+
120+
it("should show mode descriptions", async () => {
121+
mockContext.args = []
122+
123+
await modeCommand.handler(mockContext)
124+
125+
const message = mockAddMessage.mock.calls[0][0]
126+
expect(message.content).toContain("(architect)")
127+
expect(message.content).toContain("Plan and design before implementation")
128+
expect(message.content).toContain("(code)")
129+
expect(message.content).toContain("Write, modify, and refactor code")
130+
})
131+
132+
it("should show source labels for global modes", async () => {
133+
mockContext.args = []
134+
135+
await modeCommand.handler(mockContext)
136+
137+
const message = mockAddMessage.mock.calls[0][0]
138+
expect(message.content).toContain("(global)")
139+
})
140+
141+
it("should not call setMode when no arguments", async () => {
142+
mockContext.args = []
143+
144+
await modeCommand.handler(mockContext)
145+
146+
expect(mockSetMode).not.toHaveBeenCalled()
147+
})
148+
})
149+
150+
describe("handler - with arguments", () => {
151+
it("should switch to valid mode", async () => {
152+
mockContext.args = ["code"]
153+
154+
await modeCommand.handler(mockContext)
155+
156+
expect(mockSetMode).toHaveBeenCalledWith("code")
157+
})
158+
159+
it("should show success message when switching mode", async () => {
160+
mockContext.args = ["architect"]
161+
162+
await modeCommand.handler(mockContext)
163+
164+
expect(mockAddMessage).toHaveBeenCalledTimes(1)
165+
const message = mockAddMessage.mock.calls[0][0]
166+
expect(message.type).toBe("system")
167+
expect(message.content).toContain("Switched to **Architect** mode")
168+
})
169+
170+
it("should be case-insensitive", async () => {
171+
mockContext.args = ["CODE"]
172+
173+
await modeCommand.handler(mockContext)
174+
175+
expect(mockSetMode).toHaveBeenCalledWith("code")
176+
})
177+
178+
it("should show error for invalid mode", async () => {
179+
mockContext.args = ["invalid-mode"]
180+
181+
await modeCommand.handler(mockContext)
182+
183+
expect(mockAddMessage).toHaveBeenCalledTimes(1)
184+
const message = mockAddMessage.mock.calls[0][0]
185+
expect(message.type).toBe("error")
186+
expect(message.content).toContain('Invalid mode "invalid-mode"')
187+
expect(message.content).toContain("Available modes:")
188+
})
189+
190+
it("should not call setMode for invalid mode", async () => {
191+
mockContext.args = ["invalid-mode"]
192+
193+
await modeCommand.handler(mockContext)
194+
195+
expect(mockSetMode).not.toHaveBeenCalled()
196+
})
197+
198+
it("should work with all default modes", async () => {
199+
const modes = ["architect", "code", "ask", "debug", "orchestrator"]
200+
201+
for (const mode of modes) {
202+
mockAddMessage.mockClear()
203+
mockSetMode.mockClear()
204+
mockContext.args = [mode]
205+
206+
await modeCommand.handler(mockContext)
207+
208+
expect(mockSetMode).toHaveBeenCalledWith(mode)
209+
}
210+
})
211+
})
212+
213+
describe("handler - custom modes", () => {
214+
it("should include custom modes in available list", async () => {
215+
const customMode: ModeConfig = {
216+
slug: "custom",
217+
name: "Custom Mode",
218+
description: "A custom mode",
219+
source: "project",
220+
}
221+
mockContext.customModes = [customMode]
222+
mockContext.args = []
223+
224+
await modeCommand.handler(mockContext)
225+
226+
const message = mockAddMessage.mock.calls[0][0]
227+
expect(message.content).toContain("custom")
228+
expect(message.content).toContain("Custom Mode")
229+
expect(message.content).toContain("(project)")
230+
})
231+
232+
it("should switch to custom mode", async () => {
233+
const customMode: ModeConfig = {
234+
slug: "custom",
235+
name: "Custom Mode",
236+
description: "A custom mode",
237+
source: "project",
238+
}
239+
mockContext.customModes = [customMode]
240+
mockContext.args = ["custom"]
241+
242+
await modeCommand.handler(mockContext)
243+
244+
expect(mockSetMode).toHaveBeenCalledWith("custom")
245+
})
246+
247+
it("should show custom mode in success message", async () => {
248+
const customMode: ModeConfig = {
249+
slug: "custom",
250+
name: "Custom Mode",
251+
description: "A custom mode",
252+
source: "project",
253+
}
254+
mockContext.customModes = [customMode]
255+
mockContext.args = ["custom"]
256+
257+
await modeCommand.handler(mockContext)
258+
259+
const message = mockAddMessage.mock.calls[0][0]
260+
expect(message.content).toContain("Switched to **Custom Mode** mode")
261+
})
262+
263+
it("should show organization source label", async () => {
264+
const orgMode: ModeConfig = {
265+
slug: "org-mode",
266+
name: "Org Mode",
267+
description: "An org mode",
268+
source: "organization",
269+
}
270+
mockContext.customModes = [orgMode]
271+
mockContext.args = []
272+
273+
await modeCommand.handler(mockContext)
274+
275+
const message = mockAddMessage.mock.calls[0][0]
276+
expect(message.content).toContain("org-mode")
277+
})
278+
279+
it("should mix default and custom modes", async () => {
280+
const customMode: ModeConfig = {
281+
slug: "custom",
282+
name: "Custom Mode",
283+
description: "A custom mode",
284+
source: "project",
285+
}
286+
mockContext.customModes = [customMode]
287+
mockContext.args = []
288+
289+
await modeCommand.handler(mockContext)
290+
291+
const message = mockAddMessage.mock.calls[0][0]
292+
const content = message.content
293+
294+
// Should have all default modes
295+
expect(content).toContain("architect")
296+
expect(content).toContain("code")
297+
expect(content).toContain("ask")
298+
expect(content).toContain("debug")
299+
expect(content).toContain("orchestrator")
300+
301+
// Should have custom mode
302+
expect(content).toContain("custom")
303+
})
304+
})
305+
306+
describe("message structure", () => {
307+
it("should have valid message structure", async () => {
308+
mockContext.args = ["code"]
309+
310+
await modeCommand.handler(mockContext)
311+
312+
const message = mockAddMessage.mock.calls[0][0]
313+
expect(message).toHaveProperty("id")
314+
expect(message).toHaveProperty("type")
315+
expect(message).toHaveProperty("content")
316+
expect(message).toHaveProperty("ts")
317+
expect(typeof message.id).toBe("string")
318+
expect(typeof message.type).toBe("string")
319+
expect(typeof message.content).toBe("string")
320+
expect(typeof message.ts).toBe("number")
321+
})
322+
323+
it("should use current timestamp", async () => {
324+
mockContext.args = ["code"]
325+
const beforeTime = Date.now()
326+
327+
await modeCommand.handler(mockContext)
328+
329+
const message = mockAddMessage.mock.calls[0][0]
330+
expect(message.ts).toBeGreaterThanOrEqual(beforeTime)
331+
expect(message.ts).toBeLessThanOrEqual(Date.now())
332+
})
333+
})
334+
})

cli/src/commands/core/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
* Command system type definitions
33
*/
44

5-
import type { ExtensionMessage, RouterModels, WebviewMessage } from "../../types/messages.js"
5+
import type { ExtensionMessage, RouterModels, WebviewMessage, ModeConfig } from "../../types/messages.js"
66
import type { CliMessage } from "../../types/cli.js"
77
import type { CLIConfig, ProviderConfig } from "../../config/types.js"
88
import type { ProfileData, BalanceData } from "../../state/atoms/profile.js"
@@ -61,6 +61,8 @@ export interface CommandContext {
6161
balanceData: BalanceData | null
6262
profileLoading: boolean
6363
balanceLoading: boolean
64+
// Custom modes context
65+
customModes: ModeConfig[]
6466
// Task history context
6567
taskHistoryData: TaskHistoryData | null
6668
taskHistoryFilters: TaskHistoryFilters

0 commit comments

Comments
 (0)