Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions .changeset/public-radios-pull.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Improve "/model list" command with pagination, filters and sorting
8 changes: 8 additions & 0 deletions cli/src/commands/__tests__/helpers/mockContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ export function createMockContext(overrides: Partial<CommandContext> = {}): Comm
previousTaskHistoryPage: vi.fn().mockResolvedValue(null),
sendWebviewMessage: vi.fn().mockResolvedValue(undefined),
chatMessages: [],
modelListPageIndex: 0,
modelListFilters: {
sort: "preferred",
capabilities: [],
},
updateModelListFilters: vi.fn(),
changeModelListPage: vi.fn(),
resetModelListState: vi.fn(),
}

return {
Expand Down
240 changes: 225 additions & 15 deletions cli/src/commands/__tests__/model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

import { describe, it, expect, vi, beforeEach } from "vitest"
import { modelCommand } from "../model.js"
import { createMockContext } from "./helpers/mockContext.js"
import type { CommandContext } from "../core/types.js"
import type { RouterModels } from "../../types/messages.js"
import type { ProviderConfig } from "../../config/types.js"
import type { ModelRecord } from "../../constants/providers/models.js"

describe("/model command", () => {
let mockContext: CommandContext
Expand Down Expand Up @@ -51,26 +53,35 @@ describe("/model command", () => {
apiKey: "test-key",
}

// Create many models for pagination testing
const createManyModels = (count: number): ModelRecord => {
const models: ModelRecord = {}
for (let i = 1; i <= count; i++) {
models[`model-${i}`] = {
contextWindow: 100000 + i * 1000,
supportsPromptCache: i % 2 === 0,
supportsImages: i % 3 === 0,
inputPrice: i * 0.5,
outputPrice: i * 1.0,
displayName: `Model ${i}`,
}
}
return models
}

beforeEach(() => {
addMessageMock = vi.fn()
updateProviderModelMock = vi.fn().mockResolvedValue(undefined)

mockContext = {
mockContext = createMockContext({
input: "/model",
args: [],
options: {},
sendMessage: vi.fn().mockResolvedValue(undefined),
addMessage: addMessageMock,
clearMessages: vi.fn(),
clearTask: vi.fn().mockResolvedValue(undefined),
setMode: vi.fn(),
exit: vi.fn(),
routerModels: mockRouterModels,
currentProvider: mockProvider,
kilocodeDefaultModel: "",
updateProviderModel: updateProviderModelMock,
refreshRouterModels: vi.fn().mockResolvedValue(undefined),
}
addMessage: addMessageMock,
})
})

describe("Command metadata", () => {
Expand Down Expand Up @@ -107,7 +118,7 @@ describe("/model command", () => {

it("should have arguments defined", () => {
expect(modelCommand.arguments).toBeDefined()
expect(modelCommand.arguments).toHaveLength(2)
expect(modelCommand.arguments).toHaveLength(3)
})

it("should have subcommand argument with values", () => {
Expand Down Expand Up @@ -318,7 +329,7 @@ describe("/model command", () => {
await modelCommand.handler(mockContext)

const message = addMessageMock.mock.calls[0][0]
expect(message.content).toContain("Filtered by")
expect(message.content).toContain('Search: "gpt-4"')
expect(message.content).toContain("gpt-4")
expect(message.content).not.toContain("gpt-3.5-turbo")
})
Expand All @@ -333,12 +344,11 @@ describe("/model command", () => {
expect(message.content).toContain("No models found")
})

it("should display model count", async () => {
it("should display model count with pagination", async () => {
await modelCommand.handler(mockContext)

const message = addMessageMock.mock.calls[0][0]
expect(message.content).toContain("Total:")
expect(message.content).toContain("2 models")
expect(message.content).toContain("Showing 1-2 of 2")
})

it("should show error when no provider configured", async () => {
Expand Down Expand Up @@ -429,4 +439,204 @@ describe("/model command", () => {
}
})
})

describe("Model list pagination", () => {
beforeEach(() => {
mockContext.routerModels = {
...mockRouterModels,
openrouter: createManyModels(25),
}
mockContext.args = ["list"]
})

it("should paginate results with 10 items per page", async () => {
await modelCommand.handler(mockContext)

const message = addMessageMock.mock.calls[0][0]
expect(message.content).toContain("Showing 1-10 of 25")
expect(message.content).toContain("Page 1/3")
})

it("should navigate to specific page", async () => {
mockContext.args = ["list", "page", "2"]

await modelCommand.handler(mockContext)

expect(mockContext.changeModelListPage).toHaveBeenCalledWith(1)
})

it("should go to next page", async () => {
mockContext.args = ["list", "next"]
mockContext.modelListPageIndex = 0

await modelCommand.handler(mockContext)

expect(mockContext.changeModelListPage).toHaveBeenCalledWith(1)
})

it("should go to previous page", async () => {
mockContext.args = ["list", "prev"]
mockContext.modelListPageIndex = 1

await modelCommand.handler(mockContext)

expect(mockContext.changeModelListPage).toHaveBeenCalledWith(0)
})

it("should show error when already on first page", async () => {
mockContext.args = ["list", "prev"]
mockContext.modelListPageIndex = 0

await modelCommand.handler(mockContext)

const message = addMessageMock.mock.calls[0][0]
expect(message.type).toBe("system")
expect(message.content).toContain("Already on the first page")
})

it("should show error when already on last page", async () => {
mockContext.args = ["list", "next"]
mockContext.modelListPageIndex = 2

await modelCommand.handler(mockContext)

const message = addMessageMock.mock.calls[0][0]
expect(message.type).toBe("system")
expect(message.content).toContain("Already on the last page")
})

it("should validate page number", async () => {
mockContext.args = ["list", "page", "invalid"]

await modelCommand.handler(mockContext)

const message = addMessageMock.mock.calls[0][0]
expect(message.type).toBe("error")
expect(message.content).toContain("Invalid page number")
})

it("should validate page number is within range", async () => {
mockContext.args = ["list", "page", "10"]

await modelCommand.handler(mockContext)

const message = addMessageMock.mock.calls[0][0]
expect(message.type).toBe("error")
expect(message.content).toContain("Must be between 1 and")
})
})

describe("Model list sorting", () => {
beforeEach(() => {
mockContext.args = ["list"]
})

it("should sort by name", async () => {
mockContext.args = ["list", "sort", "name"]

await modelCommand.handler(mockContext)

expect(mockContext.updateModelListFilters).toHaveBeenCalledWith({ sort: "name" })
})

it("should sort by context window", async () => {
mockContext.args = ["list", "sort", "context"]

await modelCommand.handler(mockContext)

expect(mockContext.updateModelListFilters).toHaveBeenCalledWith({ sort: "context" })
})

it("should sort by price", async () => {
mockContext.args = ["list", "sort", "price"]

await modelCommand.handler(mockContext)

expect(mockContext.updateModelListFilters).toHaveBeenCalledWith({ sort: "price" })
})

it("should show error for invalid sort option", async () => {
mockContext.args = ["list", "sort", "invalid"]

await modelCommand.handler(mockContext)

const message = addMessageMock.mock.calls[0][0]
expect(message.type).toBe("error")
expect(message.content).toContain("Invalid sort option")
})

it("should show error when sort option is missing", async () => {
mockContext.args = ["list", "sort"]

await modelCommand.handler(mockContext)

const message = addMessageMock.mock.calls[0][0]
expect(message.type).toBe("error")
expect(message.content).toContain("Usage: /model list sort")
})
})

describe("Model list filtering", () => {
beforeEach(() => {
mockContext.args = ["list"]
})

it("should filter by images capability", async () => {
mockContext.args = ["list", "filter", "images"]

await modelCommand.handler(mockContext)

expect(mockContext.updateModelListFilters).toHaveBeenCalledWith({
capabilities: ["images"],
})
})

it("should filter by cache capability", async () => {
mockContext.args = ["list", "filter", "cache"]

await modelCommand.handler(mockContext)

expect(mockContext.updateModelListFilters).toHaveBeenCalledWith({
capabilities: ["cache"],
})
})

it("should toggle filter off when already active", async () => {
mockContext.args = ["list", "filter", "images"]
mockContext.modelListFilters = {
sort: "preferred",
capabilities: ["images"],
}

await modelCommand.handler(mockContext)

expect(mockContext.updateModelListFilters).toHaveBeenCalledWith({
capabilities: [],
})
})

it("should clear all filters", async () => {
mockContext.args = ["list", "filter", "all"]
mockContext.modelListFilters = {
sort: "preferred",
capabilities: ["images", "cache"],
}

await modelCommand.handler(mockContext)

expect(mockContext.updateModelListFilters).toHaveBeenCalledWith({
capabilities: [],
})
})

it("should show error for invalid filter option", async () => {
mockContext.args = ["list", "filter", "invalid"]

await modelCommand.handler(mockContext)

const message = addMessageMock.mock.calls[0][0]
expect(message.type).toBe("error")
expect(message.content).toContain("Invalid filter option")
})
})
})
7 changes: 7 additions & 0 deletions cli/src/commands/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { CliMessage } from "../../types/cli.js"
import type { CLIConfig, ProviderConfig } from "../../config/types.js"
import type { ProfileData, BalanceData } from "../../state/atoms/profile.js"
import type { TaskHistoryData, TaskHistoryFilters } from "../../state/atoms/taskHistory.js"
import type { ModelListFilters } from "../../state/atoms/modelList.js"

export interface Command {
name: string
Expand Down Expand Up @@ -76,6 +77,12 @@ export interface CommandContext {
sendWebviewMessage: (message: WebviewMessage) => Promise<void>
refreshTerminal: () => Promise<void>
chatMessages: ExtensionMessage[]
// Model list context
modelListPageIndex: number
modelListFilters: ModelListFilters
updateModelListFilters: (filters: Partial<ModelListFilters>) => void
changeModelListPage: (pageIndex: number) => void
resetModelListState: () => void
}

export type CommandHandler = (context: CommandContext) => Promise<void> | void
Expand Down
Loading