Skip to content
Open
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
22 changes: 22 additions & 0 deletions packages/types/src/__tests__/global-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,28 @@ import {
globalSettingsSchema,
} from "../global-settings.js"

describe("chat display global settings", () => {
it.each(["marquee", "breathing"])("accepts chatInputEffect %s", (chatInputEffect) => {
expect(globalSettingsSchema.parse({ chatInputEffect })).toEqual({ chatInputEffect })
})

it.each([true, false])("accepts tableStriped %s", (tableStriped) => {
expect(globalSettingsSchema.parse({ tableStriped })).toEqual({ tableStriped })
})

it("allows both settings to be omitted", () => {
expect(globalSettingsSchema.parse({})).toEqual({})
})

it.each(["none", true, 1, null])("rejects invalid chatInputEffect %s", (chatInputEffect) => {
expect(() => globalSettingsSchema.parse({ chatInputEffect })).toThrow()
})

it.each(["true", "false", 0, 1, null])("rejects non-boolean tableStriped %s", (tableStriped) => {
expect(() => globalSettingsSchema.parse({ tableStriped })).toThrow()
})
})

describe("destructive command guard global setting", () => {
it("is opt-in by default", () => {
expect(DEFAULT_DESTRUCTIVE_COMMAND_GUARD_ENABLED).toBe(false)
Expand Down
13 changes: 13 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,19 @@ export const globalSettingsSchema = z.object({
* @default "send"
*/
enterBehavior: z.enum(["send", "newline"]).optional(),
/**
* Controls the border effect on the chat input box while AI is working.
* - "marquee": A conic-gradient light beam orbits the ring (default)
* - "breathing": A pulsing glow + 1px blue border ring
* @default "marquee"
*/
chatInputEffect: z.enum(["marquee", "breathing"]).optional(),
/**
* Opt-in zebra striping (alternating background colors) for rows in
* markdown tables rendered in the chat.
* @default false
*/
tableStriped: z.boolean().optional(),
profileThresholds: z.record(z.string(), z.number()).optional(),
hasOpenedModeSelector: z.boolean().optional(),
lastModeExportPath: z.string().optional(),
Expand Down
4 changes: 4 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import type { WorktreeIncludeStatus } from "./worktree.js"
*/
export interface ExtensionMessage {
type:
| "settingsSaveResult"
| "action"
| "state"
| "taskHistoryUpdated"
Expand Down Expand Up @@ -154,6 +155,7 @@ export interface ExtensionMessage {
/** Generic payload for extension messages that use `values` */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
values?: Record<string, any>
unsavedSettings?: string[]
requestId?: string
themeFixture?: WebviewThemeFixture
promptText?: string
Expand Down Expand Up @@ -325,6 +327,8 @@ export type ExtensionState = Pick<
| "reasoningBlockCollapsed"
| "chatFontSize"
| "enterBehavior"
| "chatInputEffect"
| "tableStriped"
| "includeCurrentTime"
| "includeCurrentCost"
| "maxGitStatusFiles"
Expand Down
15 changes: 6 additions & 9 deletions src/core/config/ContextProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,13 +360,13 @@ export class ContextProxy {
return value !== undefined ? value : defaultValue
}

updateGlobalState<K extends GlobalStateKey>(key: K, value: GlobalState[K]) {
async updateGlobalState<K extends GlobalStateKey>(key: K, value: GlobalState[K]) {
if (isPassThroughStateKey(key)) {
return this.originalContext.globalState.update(key, value)
}

await this.originalContext.globalState.update(key, value)
this.stateCache[key] = value
return this.originalContext.globalState.update(key, value)
}

private getAllGlobalState(): GlobalState {
Expand All @@ -382,14 +382,11 @@ export class ContextProxy {
return this.secretCache[key]
}

storeSecret(key: SecretStateKey, value?: string) {
// Update cache.
this.secretCache[key] = value

// Write directly to context.
return value === undefined
async storeSecret(key: SecretStateKey, value?: string) {
await (value === undefined
? this.originalContext.secrets.delete(key)
: this.originalContext.secrets.store(key, value)
: this.originalContext.secrets.store(key, value))
this.secretCache[key] = value
}

/**
Expand Down
23 changes: 23 additions & 0 deletions src/core/config/__tests__/ContextProxy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,13 @@ describe("ContextProxy", () => {
})

describe("updateGlobalState", () => {
it("retains the saved cache value when persistence fails", async () => {
await proxy.updateGlobalState("chatInputEffect", "marquee")
mockGlobalState.update.mockRejectedValueOnce(new Error("disk full"))
await expect(proxy.updateGlobalState("chatInputEffect", "breathing")).rejects.toThrow("disk full")
expect(proxy.getGlobalState("chatInputEffect")).toBe("marquee")
})

it("should update state directly in original context", async () => {
await proxy.updateGlobalState("apiProvider", "deepseek")

Expand Down Expand Up @@ -208,6 +215,22 @@ describe("ContextProxy", () => {
})

describe("storeSecret", () => {
it("retains the saved secret when deletion fails", async () => {
await proxy.storeSecret("apiKey", "saved-secret")
mockSecrets.delete.mockRejectedValueOnce(new Error("storage unavailable"))

await expect(proxy.storeSecret("apiKey", undefined)).rejects.toThrow("storage unavailable")
expect(mockSecrets.delete).toHaveBeenCalledWith("apiKey")
expect(proxy.getSecret("apiKey")).toBe("saved-secret")
})

it("retains the saved secret when persistence fails", async () => {
await proxy.storeSecret("apiKey", "saved-secret")
mockSecrets.store.mockRejectedValueOnce(new Error("storage unavailable"))
await expect(proxy.storeSecret("apiKey", "unsaved-secret")).rejects.toThrow("storage unavailable")
expect(proxy.getSecret("apiKey")).toBe("saved-secret")
})

it("should store secret directly in original context", async () => {
await proxy.storeSecret("apiKey", "new-secret")

Expand Down
6 changes: 6 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2652,6 +2652,8 @@
reasoningBlockCollapsed,
chatFontSize,
enterBehavior,
chatInputEffect,
tableStriped,
cloudUserInfo,
cloudIsAuthenticated,
sharingEnabled,
Expand Down Expand Up @@ -2831,6 +2833,8 @@
reasoningBlockCollapsed: reasoningBlockCollapsed ?? true,
chatFontSize,
enterBehavior: enterBehavior ?? "send",
chatInputEffect: chatInputEffect ?? "marquee",

Check warning on line 2836 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:2836: NoCoverage StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.

Check warning on line 2836 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:2836: NoCoverage StringLiteral mutant (replacement: ""). See the job summary for the complete list and resolution guidance.
tableStriped: tableStriped ?? false,

Check warning on line 2837 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:2837: NoCoverage BooleanLiteral mutant (replacement: true). See the job summary for the complete list and resolution guidance.

Check warning on line 2837 in src/core/webview/ClineProvider.ts

View workflow job for this annotation

GitHub Actions / mutation-diff

Mutation test advisory

src/core/webview/ClineProvider.ts:2837: NoCoverage BooleanLiteral mutant (replacement: true). See the job summary for the complete list and resolution guidance.
cloudUserInfo,
cloudIsAuthenticated: cloudIsAuthenticated ?? false,
cloudAuthSkipModel: this.context.globalState.get<boolean>("roo-auth-skip-model") ?? false,
Expand Down Expand Up @@ -3062,6 +3066,8 @@
reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true,
chatFontSize: stateValues.chatFontSize,
enterBehavior: stateValues.enterBehavior ?? "send",
chatInputEffect: stateValues.chatInputEffect ?? "marquee",
tableStriped: stateValues.tableStriped ?? false,
cloudUserInfo,
cloudIsAuthenticated,
sharingEnabled,
Expand Down
22 changes: 22 additions & 0 deletions src/core/webview/__tests__/ClineProvider.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1552,6 +1552,28 @@ describe("ClineProvider", () => {
expect(state).toHaveProperty("writeDelayMs")
})

describe.each(["getState", "getStateToPostToWebview"] as const)("%s chat appearance settings", (method) => {
test.each([
{ chatInputEffect: "breathing", tableStriped: true },
{ chatInputEffect: "marquee", tableStriped: false },
] as const)(
"returns saved chatInputEffect=$chatInputEffect and tableStriped=$tableStriped",
async (settings) => {
await provider.resolveWebviewView(mockWebviewView)
await provider.contextProxy.setValue("chatInputEffect", settings.chatInputEffect)
await provider.contextProxy.setValue("tableStriped", settings.tableStriped)

expect(await provider[method]()).toMatchObject(settings)
},
)

test("defaults unset chatInputEffect to marquee and tableStriped to false", async () => {
await provider.resolveWebviewView(mockWebviewView)

expect(await provider[method]()).toMatchObject({ chatInputEffect: "marquee", tableStriped: false })
})
})

test("getState and getStateToPostToWebview return the complete NanoGPT configuration", async () => {
await provider.resolveWebviewView(mockWebviewView)
await provider.contextProxy.setProviderSettings({
Expand Down
Loading
Loading