Skip to content

Commit 175a109

Browse files
CLI - Model list pagination, filtering and sorting
CLI - Model list pagination, filtering and sorting
2 parents 44ebf95 + 78db59c commit 175a109

File tree

10 files changed

+925
-49
lines changed

10 files changed

+925
-49
lines changed

.changeset/public-radios-pull.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@kilocode/cli": patch
3+
---
4+
5+
Improve "/model list" command with pagination, filters and sorting

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ export function createMockContext(overrides: Partial<CommandContext> = {}): Comm
7171
previousTaskHistoryPage: vi.fn().mockResolvedValue(null),
7272
sendWebviewMessage: vi.fn().mockResolvedValue(undefined),
7373
chatMessages: [],
74+
modelListPageIndex: 0,
75+
modelListFilters: {
76+
sort: "preferred",
77+
capabilities: [],
78+
},
79+
updateModelListFilters: vi.fn(),
80+
changeModelListPage: vi.fn(),
81+
resetModelListState: vi.fn(),
7482
}
7583

7684
return {

cli/src/commands/__tests__/model.test.ts

Lines changed: 241 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
import { describe, it, expect, vi, beforeEach } from "vitest"
66
import { modelCommand } from "../model.js"
7+
import { createMockContext } from "./helpers/mockContext.js"
78
import type { CommandContext } from "../core/types.js"
89
import type { RouterModels } from "../../types/messages.js"
910
import type { ProviderConfig } from "../../config/types.js"
11+
import type { ModelRecord } from "../../constants/providers/models.js"
1012

1113
describe("/model command", () => {
1214
let mockContext: CommandContext
@@ -51,26 +53,35 @@ describe("/model command", () => {
5153
apiKey: "test-key",
5254
}
5355

56+
// Create many models for pagination testing
57+
const createManyModels = (count: number): ModelRecord => {
58+
const models: ModelRecord = {}
59+
for (let i = 1; i <= count; i++) {
60+
models[`model-${i}`] = {
61+
contextWindow: 100000 + i * 1000,
62+
supportsPromptCache: i % 2 === 0,
63+
supportsImages: i % 3 === 0,
64+
inputPrice: i * 0.5,
65+
outputPrice: i * 1.0,
66+
displayName: `Model ${i}`,
67+
}
68+
}
69+
return models
70+
}
71+
5472
beforeEach(() => {
5573
addMessageMock = vi.fn()
5674
updateProviderModelMock = vi.fn().mockResolvedValue(undefined)
5775

58-
mockContext = {
76+
mockContext = createMockContext({
5977
input: "/model",
6078
args: [],
61-
options: {},
62-
sendMessage: vi.fn().mockResolvedValue(undefined),
63-
addMessage: addMessageMock,
64-
clearMessages: vi.fn(),
65-
clearTask: vi.fn().mockResolvedValue(undefined),
66-
setMode: vi.fn(),
67-
exit: vi.fn(),
6879
routerModels: mockRouterModels,
6980
currentProvider: mockProvider,
7081
kilocodeDefaultModel: "",
7182
updateProviderModel: updateProviderModelMock,
72-
refreshRouterModels: vi.fn().mockResolvedValue(undefined),
73-
}
83+
addMessage: addMessageMock,
84+
})
7485
})
7586

7687
describe("Command metadata", () => {
@@ -107,7 +118,7 @@ describe("/model command", () => {
107118

108119
it("should have arguments defined", () => {
109120
expect(modelCommand.arguments).toBeDefined()
110-
expect(modelCommand.arguments).toHaveLength(2)
121+
expect(modelCommand.arguments).toHaveLength(3)
111122
})
112123

113124
it("should have subcommand argument with values", () => {
@@ -313,32 +324,47 @@ describe("/model command", () => {
313324
})
314325

315326
it("should filter models when filter is provided", async () => {
327+
// Mock updateModelListFilters to actually update the filters
328+
const updateFiltersMock = vi.fn((filters) => {
329+
mockContext.modelListFilters = { ...mockContext.modelListFilters, ...filters }
330+
})
331+
mockContext.updateModelListFilters = updateFiltersMock
316332
mockContext.args = ["list", "gpt-4"]
317333

318334
await modelCommand.handler(mockContext)
319335

336+
// Verify the filter was persisted
337+
expect(updateFiltersMock).toHaveBeenCalledWith({ search: "gpt-4" })
338+
320339
const message = addMessageMock.mock.calls[0][0]
321-
expect(message.content).toContain("Filtered by")
340+
expect(message.content).toContain('Search: "gpt-4"')
322341
expect(message.content).toContain("gpt-4")
323342
expect(message.content).not.toContain("gpt-3.5-turbo")
324343
})
325344

326345
it("should show message when no models match filter", async () => {
346+
// Mock updateModelListFilters to actually update the filters
347+
const updateFiltersMock = vi.fn((filters) => {
348+
mockContext.modelListFilters = { ...mockContext.modelListFilters, ...filters }
349+
})
350+
mockContext.updateModelListFilters = updateFiltersMock
327351
mockContext.args = ["list", "nonexistent"]
328352

329353
await modelCommand.handler(mockContext)
330354

355+
// Verify the filter was persisted
356+
expect(updateFiltersMock).toHaveBeenCalledWith({ search: "nonexistent" })
357+
331358
const message = addMessageMock.mock.calls[0][0]
332359
expect(message.type).toBe("system")
333360
expect(message.content).toContain("No models found")
334361
})
335362

336-
it("should display model count", async () => {
363+
it("should display model count with pagination", async () => {
337364
await modelCommand.handler(mockContext)
338365

339366
const message = addMessageMock.mock.calls[0][0]
340-
expect(message.content).toContain("Total:")
341-
expect(message.content).toContain("2 models")
367+
expect(message.content).toContain("Showing 1-2 of 2")
342368
})
343369

344370
it("should show error when no provider configured", async () => {
@@ -429,4 +455,204 @@ describe("/model command", () => {
429455
}
430456
})
431457
})
458+
459+
describe("Model list pagination", () => {
460+
beforeEach(() => {
461+
mockContext.routerModels = {
462+
...mockRouterModels,
463+
openrouter: createManyModels(25),
464+
}
465+
mockContext.args = ["list"]
466+
})
467+
468+
it("should paginate results with 10 items per page", async () => {
469+
await modelCommand.handler(mockContext)
470+
471+
const message = addMessageMock.mock.calls[0][0]
472+
expect(message.content).toContain("Showing 1-10 of 25")
473+
expect(message.content).toContain("Page 1/3")
474+
})
475+
476+
it("should navigate to specific page", async () => {
477+
mockContext.args = ["list", "page", "2"]
478+
479+
await modelCommand.handler(mockContext)
480+
481+
expect(mockContext.changeModelListPage).toHaveBeenCalledWith(1)
482+
})
483+
484+
it("should go to next page", async () => {
485+
mockContext.args = ["list", "next"]
486+
mockContext.modelListPageIndex = 0
487+
488+
await modelCommand.handler(mockContext)
489+
490+
expect(mockContext.changeModelListPage).toHaveBeenCalledWith(1)
491+
})
492+
493+
it("should go to previous page", async () => {
494+
mockContext.args = ["list", "prev"]
495+
mockContext.modelListPageIndex = 1
496+
497+
await modelCommand.handler(mockContext)
498+
499+
expect(mockContext.changeModelListPage).toHaveBeenCalledWith(0)
500+
})
501+
502+
it("should show error when already on first page", async () => {
503+
mockContext.args = ["list", "prev"]
504+
mockContext.modelListPageIndex = 0
505+
506+
await modelCommand.handler(mockContext)
507+
508+
const message = addMessageMock.mock.calls[0][0]
509+
expect(message.type).toBe("system")
510+
expect(message.content).toContain("Already on the first page")
511+
})
512+
513+
it("should show error when already on last page", async () => {
514+
mockContext.args = ["list", "next"]
515+
mockContext.modelListPageIndex = 2
516+
517+
await modelCommand.handler(mockContext)
518+
519+
const message = addMessageMock.mock.calls[0][0]
520+
expect(message.type).toBe("system")
521+
expect(message.content).toContain("Already on the last page")
522+
})
523+
524+
it("should validate page number", async () => {
525+
mockContext.args = ["list", "page", "invalid"]
526+
527+
await modelCommand.handler(mockContext)
528+
529+
const message = addMessageMock.mock.calls[0][0]
530+
expect(message.type).toBe("error")
531+
expect(message.content).toContain("Invalid page number")
532+
})
533+
534+
it("should validate page number is within range", async () => {
535+
mockContext.args = ["list", "page", "10"]
536+
537+
await modelCommand.handler(mockContext)
538+
539+
const message = addMessageMock.mock.calls[0][0]
540+
expect(message.type).toBe("error")
541+
expect(message.content).toContain("Must be between 1 and")
542+
})
543+
})
544+
545+
describe("Model list sorting", () => {
546+
beforeEach(() => {
547+
mockContext.args = ["list"]
548+
})
549+
550+
it("should sort by name", async () => {
551+
mockContext.args = ["list", "sort", "name"]
552+
553+
await modelCommand.handler(mockContext)
554+
555+
expect(mockContext.updateModelListFilters).toHaveBeenCalledWith({ sort: "name" })
556+
})
557+
558+
it("should sort by context window", async () => {
559+
mockContext.args = ["list", "sort", "context"]
560+
561+
await modelCommand.handler(mockContext)
562+
563+
expect(mockContext.updateModelListFilters).toHaveBeenCalledWith({ sort: "context" })
564+
})
565+
566+
it("should sort by price", async () => {
567+
mockContext.args = ["list", "sort", "price"]
568+
569+
await modelCommand.handler(mockContext)
570+
571+
expect(mockContext.updateModelListFilters).toHaveBeenCalledWith({ sort: "price" })
572+
})
573+
574+
it("should show error for invalid sort option", async () => {
575+
mockContext.args = ["list", "sort", "invalid"]
576+
577+
await modelCommand.handler(mockContext)
578+
579+
const message = addMessageMock.mock.calls[0][0]
580+
expect(message.type).toBe("error")
581+
expect(message.content).toContain("Invalid sort option")
582+
})
583+
584+
it("should show error when sort option is missing", async () => {
585+
mockContext.args = ["list", "sort"]
586+
587+
await modelCommand.handler(mockContext)
588+
589+
const message = addMessageMock.mock.calls[0][0]
590+
expect(message.type).toBe("error")
591+
expect(message.content).toContain("Usage: /model list sort")
592+
})
593+
})
594+
595+
describe("Model list filtering", () => {
596+
beforeEach(() => {
597+
mockContext.args = ["list"]
598+
})
599+
600+
it("should filter by images capability", async () => {
601+
mockContext.args = ["list", "filter", "images"]
602+
603+
await modelCommand.handler(mockContext)
604+
605+
expect(mockContext.updateModelListFilters).toHaveBeenCalledWith({
606+
capabilities: ["images"],
607+
})
608+
})
609+
610+
it("should filter by cache capability", async () => {
611+
mockContext.args = ["list", "filter", "cache"]
612+
613+
await modelCommand.handler(mockContext)
614+
615+
expect(mockContext.updateModelListFilters).toHaveBeenCalledWith({
616+
capabilities: ["cache"],
617+
})
618+
})
619+
620+
it("should toggle filter off when already active", async () => {
621+
mockContext.args = ["list", "filter", "images"]
622+
mockContext.modelListFilters = {
623+
sort: "preferred",
624+
capabilities: ["images"],
625+
}
626+
627+
await modelCommand.handler(mockContext)
628+
629+
expect(mockContext.updateModelListFilters).toHaveBeenCalledWith({
630+
capabilities: [],
631+
})
632+
})
633+
634+
it("should clear all filters", async () => {
635+
mockContext.args = ["list", "filter", "all"]
636+
mockContext.modelListFilters = {
637+
sort: "preferred",
638+
capabilities: ["images", "cache"],
639+
}
640+
641+
await modelCommand.handler(mockContext)
642+
643+
expect(mockContext.updateModelListFilters).toHaveBeenCalledWith({
644+
capabilities: [],
645+
})
646+
})
647+
648+
it("should show error for invalid filter option", async () => {
649+
mockContext.args = ["list", "filter", "invalid"]
650+
651+
await modelCommand.handler(mockContext)
652+
653+
const message = addMessageMock.mock.calls[0][0]
654+
expect(message.type).toBe("error")
655+
expect(message.content).toContain("Invalid filter option")
656+
})
657+
})
432658
})

cli/src/commands/core/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ 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"
99
import type { TaskHistoryData, TaskHistoryFilters } from "../../state/atoms/taskHistory.js"
10+
import type { ModelListFilters } from "../../state/atoms/modelList.js"
1011

1112
export interface Command {
1213
name: string
@@ -76,6 +77,12 @@ export interface CommandContext {
7677
sendWebviewMessage: (message: WebviewMessage) => Promise<void>
7778
refreshTerminal: () => Promise<void>
7879
chatMessages: ExtensionMessage[]
80+
// Model list context
81+
modelListPageIndex: number
82+
modelListFilters: ModelListFilters
83+
updateModelListFilters: (filters: Partial<ModelListFilters>) => void
84+
changeModelListPage: (pageIndex: number) => void
85+
resetModelListState: () => void
7986
}
8087

8188
export type CommandHandler = (context: CommandContext) => Promise<void> | void

0 commit comments

Comments
 (0)