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
5 changes: 5 additions & 0 deletions .changeset/shift-tab-variant-cycle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---

Cycle reasoning effort variants with Shift+Tab in prompt inputs. Works in the sidebar chat, Agent Manager, and the New Worktree dialog. The variant selector tooltip shows the shortcut on hover, and the behavior can be turned off with the `kilo-code.new.chat.shiftTabCyclesVariant` setting (also available under Settings > Display) to restore Shift+Tab focus navigation.
5 changes: 5 additions & 0 deletions packages/kilo-vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -1129,6 +1129,11 @@
"default": true,
"description": "Show the task timeline graph in the chat header"
},
"kilo-code.new.chat.shiftTabCyclesVariant": {
"type": "boolean",
"default": true,
"description": "Cycle through reasoning effort variants with Shift+Tab in prompt inputs. Disable to keep Shift+Tab for keyboard focus navigation."
},
"kilo-code.new.agentWorkStyle": {
"type": "string",
"scope": "application",
Expand Down
9 changes: 9 additions & 0 deletions packages/kilo-vscode/src/KiloProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ import {
validIndexingSetting,
watchIndexingConfig,
} from "./kilo-provider/indexing-settings"
import { buildChatSettingsMessage, validChatSetting, watchChatConfig } from "./kilo-provider/chat-settings"

let maxCost = 0

Expand Down Expand Up @@ -391,6 +392,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private webviewMessageDisposable: vscode.Disposable | null = null
private autocompleteConfigDisposable: vscode.Disposable | null = null
private indexingConfigDisposable: vscode.Disposable | null = null
private chatConfigDisposable: vscode.Disposable | null = null
private telemetryStateDisposable: vscode.Disposable | null = null
private viewStateDisposable: vscode.Disposable | null = null
private visibilityDisposable: vscode.Disposable | null = null
Expand Down Expand Up @@ -913,6 +915,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.autocompleteConfigDisposable = watchAutocompleteConfig((msg) => this.postMessage(msg))
this.indexingConfigDisposable?.dispose()
this.indexingConfigDisposable = watchIndexingConfig((msg) => this.postMessage(msg))
this.chatConfigDisposable?.dispose()
this.chatConfigDisposable = watchChatConfig((msg) => this.postMessage(msg))
this.telemetryStateDisposable?.dispose()
this.telemetryStateDisposable = watchTelemetryState((msg) => this.postMessage(msg))
this.webviewMessageDisposable = webview.onDidReceiveMessage(async (message) => {
Expand Down Expand Up @@ -1250,6 +1254,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "requestIndexingSettings":
this.postMessage(buildIndexingSettingsMessage())
break
case "requestChatSettings":
this.postMessage(buildChatSettingsMessage())
break
case "requestKiloEmbeddingModels":
this.fetchAndSendKiloEmbeddingModels().catch((e) =>
console.error("[Kilo New] fetchAndSendKiloEmbeddingModels failed:", e),
Expand Down Expand Up @@ -3665,6 +3672,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
const { section, leaf } = buildSettingPath(key)
if (section === "autocomplete" && !validAutocompleteSetting(leaf, value)) return
if (section === "indexing" && !validIndexingSetting(leaf, value)) return
if (section === "chat" && !validChatSetting(leaf, value)) return
const config = vscode.workspace.getConfiguration(`kilo-code.new${section ? `.${section}` : ""}`)
// Normalize a webview-side clear to `undefined` so VS Code removes the
// key from settings.json rather than persisting a literal `null`. This
Expand Down Expand Up @@ -4508,6 +4516,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.webviewMessageDisposable?.dispose()
this.autocompleteConfigDisposable?.dispose()
this.indexingConfigDisposable?.dispose()
this.chatConfigDisposable?.dispose()
this.telemetryStateDisposable?.dispose()
this.autoApproveBridge?.dispose()
this.visibleTaskStreams.clear()
Expand Down
25 changes: 25 additions & 0 deletions packages/kilo-vscode/src/kilo-provider/chat-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import * as vscode from "vscode"

type Post = (msg: unknown) => void

export function buildChatSettingsMessage() {
const config = vscode.workspace.getConfiguration("kilo-code.new.chat")
return {
type: "chatSettingsLoaded" as const,
settings: {
shiftTabCyclesVariant: config.get<boolean>("shiftTabCyclesVariant", true),
},
}
}

export function watchChatConfig(post: Post): vscode.Disposable {
return vscode.workspace.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration("kilo-code.new.chat")) {
post(buildChatSettingsMessage())
}
})
}

export function validChatSetting(key: string, value: unknown) {
return key === "shiftTabCyclesVariant" && typeof value === "boolean"
}
54 changes: 54 additions & 0 deletions packages/kilo-vscode/tests/unit/chat-settings-message.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import * as vscode from "vscode"
import { buildChatSettingsMessage, validChatSetting } from "../../src/kilo-provider/chat-settings"

type Stub = {
getConfiguration: (section?: string) => {
get: <T>(key: string, fallback?: T) => T | undefined
}
}

const original = vscode.workspace.getConfiguration

function stubConfig(state: Map<string, unknown>) {
;(vscode.workspace as unknown as Stub).getConfiguration = (section?: string) => {
if (section !== "kilo-code.new.chat") {
return { get: <T>(_key: string, fallback?: T) => fallback }
}
return {
get: <T>(key: string, fallback?: T) => (state.has(key) ? (state.get(key) as T) : fallback),
}
}
}

afterEach(() => {
;(vscode.workspace as unknown as Stub).getConfiguration = original as Stub["getConfiguration"]
})

describe("buildChatSettingsMessage", () => {
let state: Map<string, unknown>

beforeEach(() => {
state = new Map()
stubConfig(state)
})

it("enables Shift+Tab variant cycling by default", () => {
expect(buildChatSettingsMessage().settings.shiftTabCyclesVariant).toBe(true)
})

it("returns the persisted cycling preference", () => {
state.set("shiftTabCyclesVariant", false)

expect(buildChatSettingsMessage().settings.shiftTabCyclesVariant).toBe(false)
})
})

describe("validChatSetting", () => {
it("accepts only boolean cycling updates", () => {
expect(validChatSetting("shiftTabCyclesVariant", true)).toBe(true)
expect(validChatSetting("shiftTabCyclesVariant", false)).toBe(true)
expect(validChatSetting("shiftTabCyclesVariant", "false")).toBe(false)
expect(validChatSetting("unknown", true)).toBe(false)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe("NewWorktreeDialog sandbox toggle", () => {
'vscode.postMessage({ type: "setSandboxDefault", enabled: next, requestID: sandboxRequestID })',
)
expect(src).toContain("sandbox: sandboxVisible() ? sandboxOverride() : undefined")
expect(src).toContain("const { config, globalConfig, features } = useConfig()")
expect(src).toContain("const { config, globalConfig, features, settings } = useConfig()")
expect(src).toContain(
"const sandboxVisible = () => features().sandboxControls && globalConfig().sandbox?.enabled === true",
)
Expand Down
21 changes: 21 additions & 0 deletions packages/kilo-vscode/tests/unit/session-variant-store.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "bun:test"
import {
cycleVariant,
getVariant,
sessionVariantKeys,
sessionVariants,
Expand Down Expand Up @@ -92,3 +93,23 @@ describe("per-session variant selection", () => {
expect(sessionVariantKeys(store, "pending-local-1")).toEqual(["session/pending-local-1/anthropic/claude-sonnet-4"])
})
})

describe("cycleVariant", () => {
it("advances to the next variant", () => {
expect(cycleVariant("low", variants)).toBe("medium")
expect(cycleVariant("medium", variants)).toBe("high")
})

it("wraps back to the first variant after the last", () => {
expect(cycleVariant("high", variants)).toBe("low")
})

it("starts at the first variant when current is missing or unknown", () => {
expect(cycleVariant(undefined, variants)).toBe("low")
expect(cycleVariant("bogus", variants)).toBe("low")
})

it("returns undefined when no variants exist", () => {
expect(cycleVariant("low", [])).toBeUndefined()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useServer } from "../src/context/server"
import { useSession } from "../src/context/session"
import { useProvider } from "../src/context/provider"
import { useConfig } from "../src/context/config"
import { cycleVariant } from "../src/context/session-variant-store"
import { ModelSelectorBase } from "../src/components/shared/ModelSelector"
import { ModeSwitcherBase } from "../src/components/shared/ModeSwitcher"
import { SpeechToTextButton } from "../src/components/speech-to-text/SpeechToTextButton"
Expand Down Expand Up @@ -79,7 +80,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
const server = useServer()
const session = useSession()
const provider = useProvider()
const { config, globalConfig, features } = useConfig()
const { config, globalConfig, features, settings } = useConfig()
const metrics = tracker(vscode)
const track = (button: string, properties?: Record<string, string | number | boolean | undefined>) =>
metrics.track(button, "configure_worktree_dialog", properties)
Expand Down Expand Up @@ -339,6 +340,22 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
textareaRef.focus()
}

const onKey = (e: KeyboardEvent) => {
// Shift+Tab cycles reasoning effort variants (setting: chat.shiftTabCyclesVariant).
// When disabled or no variants exist, fall through to default focus navigation.
if (e.key === "Tab" && e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
if (settings()["chat.shiftTabCyclesVariant"] === false) return
const list = variants()
if (list.length === 0) return
const next = cycleVariant(effectiveVariant(), list)
if (!next) return
e.preventDefault()
setVariant(next)
return
}
undo(e)
}

const adjustHeight = () => {
if (!textareaRef) return
textareaRef.style.height = "auto"
Expand Down Expand Up @@ -541,7 +558,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
persistPrompt(val)
adjustHeight()
}}
onKeyDown={undo}
onKeyDown={onKey}
onPaste={(e) => imageAttach.handlePaste(e)}
rows={3}
dir="auto"
Expand Down Expand Up @@ -575,6 +592,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
onSelect={setVariant}
portal={false}
deferDismiss
cycleHint={settings()["chat.shiftTabCyclesVariant"] !== false}
/>
<Show when={overridden()}>
<Tooltip value={t("prompt.action.resetModel")} placement="top">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { useSpeechToText } from "../speech-to-text/useSpeechToText"
import { useImageAttachments, type ImageAttachment } from "../../hooks/useImageAttachments"
import { convertToMentionPath } from "../../utils/path-mentions"
import { usePromptHistory } from "../../hooks/usePromptHistory"
import { cycleVariant } from "../../context/session-variant-store"
import { WandSparkles } from "@kilocode/kilo-ui/lucide"
import {
fileName,
Expand Down Expand Up @@ -863,7 +864,20 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}

if (e.key === "Tab" && ghost.text()) {
// Shift+Tab cycles reasoning effort variants (setting: chat.shiftTabCyclesVariant).
// When disabled or no variants exist, fall through to default focus navigation.
if (e.key === "Tab" && e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
if (settings()["chat.shiftTabCyclesVariant"] === false) return
const list = session.variantList(sid())
if (list.length === 0) return
const next = cycleVariant(session.currentVariant(sid()), list)
if (!next) return
e.preventDefault()
session.selectVariant(next, sid())
return
}

if (e.key === "Tab" && !e.shiftKey && ghost.text()) {
if (!isAtEnd()) return
e.preventDefault()
acceptSuggestion()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const CODE_EDIT_OPTIONS: LayoutOption[] = [
]

const DisplayTab: Component = () => {
const { config, updateConfig } = useConfig()
const { config, updateConfig, settings, updateSetting } = useConfig()
const display = useDisplay()
const language = useLanguage()

Expand Down Expand Up @@ -78,6 +78,19 @@ const DisplayTab: Component = () => {
</Switch>
</SettingsRow>

<SettingsRow
title={language.t("settings.display.shiftTabCycle.title")}
description={language.t("settings.display.shiftTabCycle.description")}
>
<Switch
checked={Boolean(settings()["chat.shiftTabCyclesVariant"] ?? true)}
onChange={(checked: boolean) => updateSetting("chat.shiftTabCyclesVariant", checked)}
hideLabel
>
{language.t("settings.display.shiftTabCycle.title")}
</Switch>
</SettingsRow>

<SettingsRow
title={language.t("settings.display.terminalCommand.title")}
description={language.t("settings.display.terminalCommand.description")}
Expand Down
Loading
Loading