kanna-code 0.35.2 → 0.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -61,6 +61,37 @@ const DEFAULT_KEYBINDINGS_SNAPSHOT: KeybindingsSnapshot = {
61
61
 
62
62
  const DEFAULT_APP_SETTINGS_SNAPSHOT: AppSettingsSnapshot = {
63
63
  analyticsEnabled: true,
64
+ browserSettingsMigrated: false,
65
+ theme: "system",
66
+ chatSoundPreference: "always",
67
+ chatSoundId: "funk",
68
+ terminal: {
69
+ scrollbackLines: 1_000,
70
+ minColumnWidth: 450,
71
+ },
72
+ editor: {
73
+ preset: "cursor",
74
+ commandTemplate: "cursor {path}",
75
+ },
76
+ defaultProvider: "last_used",
77
+ providerDefaults: {
78
+ claude: {
79
+ model: "claude-opus-4-7",
80
+ modelOptions: {
81
+ reasoningEffort: "high",
82
+ contextWindow: "200k",
83
+ },
84
+ planMode: false,
85
+ },
86
+ codex: {
87
+ model: "gpt-5.5",
88
+ modelOptions: {
89
+ reasoningEffort: "high",
90
+ fastMode: false,
91
+ },
92
+ planMode: false,
93
+ },
94
+ },
64
95
  warning: null,
65
96
  filePathDisplay: "~/.kanna/data/settings.json",
66
97
  }
@@ -163,6 +194,7 @@ describe("ws-router", () => {
163
194
  updateManager: null,
164
195
  })
165
196
  const ws = new FakeWebSocket()
197
+ router.handleOpen(ws as never)
166
198
 
167
199
  await router.handleMessage(
168
200
  ws as never,
@@ -254,6 +286,7 @@ describe("ws-router", () => {
254
286
  updateManager: null,
255
287
  })
256
288
  const ws = new FakeWebSocket()
289
+ router.handleOpen(ws as never)
257
290
 
258
291
  await router.handleMessage(
259
292
  ws as never,
@@ -298,6 +331,124 @@ describe("ws-router", () => {
298
331
  expect(writes).toEqual([{ analyticsEnabled: false }])
299
332
  })
300
333
 
334
+ test("subscribes to app settings and writes patches through the router", async () => {
335
+ let snapshot: AppSettingsSnapshot = DEFAULT_APP_SETTINGS_SNAPSHOT
336
+ let listener: ((nextSnapshot: AppSettingsSnapshot) => void) | null = null
337
+ const router = createWsRouter({
338
+ store: { state: createEmptyState() } as never,
339
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
340
+ terminals: {
341
+ getSnapshot: () => null,
342
+ onEvent: () => () => {},
343
+ } as never,
344
+ keybindings: {
345
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
346
+ onChange: () => () => {},
347
+ } as never,
348
+ appSettings: {
349
+ getSnapshot: () => snapshot,
350
+ write: async (value) => {
351
+ snapshot = { ...snapshot, analyticsEnabled: value.analyticsEnabled }
352
+ return snapshot
353
+ },
354
+ writePatch: async (patch) => {
355
+ snapshot = {
356
+ ...snapshot,
357
+ analyticsEnabled: patch.analyticsEnabled ?? snapshot.analyticsEnabled,
358
+ browserSettingsMigrated: patch.browserSettingsMigrated ?? snapshot.browserSettingsMigrated,
359
+ theme: patch.theme ?? snapshot.theme,
360
+ chatSoundPreference: patch.chatSoundPreference ?? snapshot.chatSoundPreference,
361
+ chatSoundId: patch.chatSoundId ?? snapshot.chatSoundId,
362
+ defaultProvider: patch.defaultProvider ?? snapshot.defaultProvider,
363
+ terminal: { ...snapshot.terminal, ...patch.terminal },
364
+ editor: { ...snapshot.editor, ...patch.editor },
365
+ }
366
+ listener?.(snapshot)
367
+ return snapshot
368
+ },
369
+ onChange: (nextListener) => {
370
+ listener = nextListener
371
+ return () => {
372
+ listener = null
373
+ }
374
+ },
375
+ },
376
+ refreshDiscovery: async () => [],
377
+ getDiscoveredProjects: () => [],
378
+ machineDisplayName: "Local Machine",
379
+ updateManager: null,
380
+ })
381
+ const ws = new FakeWebSocket()
382
+ router.handleOpen(ws as never)
383
+
384
+ await router.handleMessage(
385
+ ws as never,
386
+ JSON.stringify({
387
+ v: 1,
388
+ type: "subscribe",
389
+ id: "app-settings-sub-1",
390
+ topic: { type: "app-settings" },
391
+ })
392
+ )
393
+
394
+ await router.handleMessage(
395
+ ws as never,
396
+ JSON.stringify({
397
+ v: 1,
398
+ type: "command",
399
+ id: "settings-patch-1",
400
+ command: {
401
+ type: "settings.writeAppSettingsPatch",
402
+ patch: {
403
+ theme: "dark",
404
+ terminal: { scrollbackLines: 2_000 },
405
+ },
406
+ },
407
+ })
408
+ )
409
+
410
+ expect(ws.sent).toEqual([
411
+ {
412
+ v: PROTOCOL_VERSION,
413
+ type: "snapshot",
414
+ id: "app-settings-sub-1",
415
+ snapshot: {
416
+ type: "app-settings",
417
+ data: DEFAULT_APP_SETTINGS_SNAPSHOT,
418
+ },
419
+ },
420
+ {
421
+ v: PROTOCOL_VERSION,
422
+ type: "snapshot",
423
+ id: "app-settings-sub-1",
424
+ snapshot: {
425
+ type: "app-settings",
426
+ data: {
427
+ ...DEFAULT_APP_SETTINGS_SNAPSHOT,
428
+ theme: "dark",
429
+ terminal: {
430
+ ...DEFAULT_APP_SETTINGS_SNAPSHOT.terminal,
431
+ scrollbackLines: 2_000,
432
+ },
433
+ },
434
+ },
435
+ },
436
+ {
437
+ v: PROTOCOL_VERSION,
438
+ type: "ack",
439
+ id: "settings-patch-1",
440
+ result: {
441
+ ...DEFAULT_APP_SETTINGS_SNAPSHOT,
442
+ theme: "dark",
443
+ terminal: {
444
+ ...DEFAULT_APP_SETTINGS_SNAPSHOT.terminal,
445
+ scrollbackLines: 2_000,
446
+ },
447
+ },
448
+ },
449
+ ])
450
+ })
451
+
301
452
  test("tracks analytics preference transitions in the correct order", async () => {
302
453
  const analyticsEvents: string[] = []
303
454
  let analyticsEnabled = true
@@ -16,7 +16,7 @@ import { writeStandaloneTranscriptExport } from "./standalone-export"
16
16
  import { TerminalManager } from "./terminal-manager"
17
17
  import type { UpdateManager } from "./update-manager"
18
18
  import { deriveChatSnapshot, deriveLocalProjectsSnapshot, deriveSidebarData } from "./read-models"
19
- import type { AppSettingsSnapshot, LlmProviderSnapshot, LlmProviderValidationResult } from "../shared/types"
19
+ import type { AppSettingsPatch, AppSettingsSnapshot, LlmProviderSnapshot, LlmProviderValidationResult } from "../shared/types"
20
20
 
21
21
  const DEFAULT_CHAT_RECENT_LIMIT = 200
22
22
 
@@ -49,6 +49,7 @@ function countSubscriptionsByTopic(ws: ServerWebSocket<ClientState>) {
49
49
  let localProjects = 0
50
50
  let update = 0
51
51
  let keybindings = 0
52
+ let appSettings = 0
52
53
  let terminal = 0
53
54
 
54
55
  for (const topic of ws.data.subscriptions.values()) {
@@ -71,6 +72,9 @@ function countSubscriptionsByTopic(ws: ServerWebSocket<ClientState>) {
71
72
  case "keybindings":
72
73
  keybindings += 1
73
74
  break
75
+ case "app-settings":
76
+ appSettings += 1
77
+ break
74
78
  case "terminal":
75
79
  terminal += 1
76
80
  break
@@ -85,6 +89,7 @@ function countSubscriptionsByTopic(ws: ServerWebSocket<ClientState>) {
85
89
  localProjects,
86
90
  update,
87
91
  keybindings,
92
+ appSettings,
88
93
  terminal,
89
94
  }
90
95
  }
@@ -101,7 +106,7 @@ interface CreateWsRouterArgs {
101
106
  agent: AgentCoordinator
102
107
  terminals: TerminalManager
103
108
  keybindings: KeybindingsManager
104
- appSettings?: Pick<AppSettingsManager, "getSnapshot" | "write">
109
+ appSettings?: Pick<AppSettingsManager, "getSnapshot" | "write"> & Partial<Pick<AppSettingsManager, "writePatch" | "onChange">>
105
110
  analytics?: AnalyticsReporter
106
111
  llmProvider?: {
107
112
  read: () => Promise<LlmProviderSnapshot>
@@ -119,6 +124,7 @@ interface SnapshotBroadcastFilter {
119
124
  includeLocalProjects?: boolean
120
125
  includeUpdate?: boolean
121
126
  includeKeybindings?: boolean
127
+ includeAppSettings?: boolean
122
128
  chatIds?: Set<string>
123
129
  projectIds?: Set<string>
124
130
  terminalIds?: Set<string>
@@ -226,17 +232,88 @@ export function createWsRouter({
226
232
  },
227
233
  }),
228
234
  }
229
- const resolvedAppSettings = appSettings ?? {
230
- getSnapshot: () => ({
231
- analyticsEnabled: true,
232
- warning: null,
233
- filePathDisplay: "~/.kanna/data/settings.json",
234
- } satisfies AppSettingsSnapshot),
235
- write: async ({ analyticsEnabled }: { analyticsEnabled: boolean }) => ({
236
- analyticsEnabled,
237
- warning: null,
238
- filePathDisplay: "~/.kanna/data/settings.json",
239
- } satisfies AppSettingsSnapshot),
235
+ let fallbackAppSettingsSnapshot: AppSettingsSnapshot = {
236
+ analyticsEnabled: true,
237
+ browserSettingsMigrated: false,
238
+ theme: "system",
239
+ chatSoundPreference: "always",
240
+ chatSoundId: "funk",
241
+ terminal: {
242
+ scrollbackLines: 1_000,
243
+ minColumnWidth: 450,
244
+ },
245
+ editor: {
246
+ preset: "cursor",
247
+ commandTemplate: "cursor {path}",
248
+ },
249
+ defaultProvider: "last_used",
250
+ providerDefaults: {
251
+ claude: {
252
+ model: "claude-opus-4-7",
253
+ modelOptions: {
254
+ reasoningEffort: "high",
255
+ contextWindow: "200k",
256
+ },
257
+ planMode: false,
258
+ },
259
+ codex: {
260
+ model: "gpt-5.5",
261
+ modelOptions: {
262
+ reasoningEffort: "high",
263
+ fastMode: false,
264
+ },
265
+ planMode: false,
266
+ },
267
+ },
268
+ warning: null,
269
+ filePathDisplay: "~/.kanna/data/settings.json",
270
+ }
271
+ const mergeAppSettingsPatch = (snapshot: AppSettingsSnapshot, patch: AppSettingsPatch): AppSettingsSnapshot => ({
272
+ ...snapshot,
273
+ ...patch,
274
+ terminal: {
275
+ ...snapshot.terminal,
276
+ ...patch.terminal,
277
+ },
278
+ editor: {
279
+ ...snapshot.editor,
280
+ ...patch.editor,
281
+ },
282
+ providerDefaults: {
283
+ claude: {
284
+ ...snapshot.providerDefaults.claude,
285
+ ...patch.providerDefaults?.claude,
286
+ modelOptions: {
287
+ ...snapshot.providerDefaults.claude.modelOptions,
288
+ ...patch.providerDefaults?.claude?.modelOptions,
289
+ },
290
+ },
291
+ codex: {
292
+ ...snapshot.providerDefaults.codex,
293
+ ...patch.providerDefaults?.codex,
294
+ modelOptions: {
295
+ ...snapshot.providerDefaults.codex.modelOptions,
296
+ ...patch.providerDefaults?.codex?.modelOptions,
297
+ },
298
+ },
299
+ },
300
+ })
301
+ const resolvedAppSettings = {
302
+ getSnapshot: () => appSettings?.getSnapshot() ?? fallbackAppSettingsSnapshot,
303
+ write: async (value: { analyticsEnabled: boolean }) => {
304
+ if (appSettings) return await appSettings.write(value)
305
+ fallbackAppSettingsSnapshot = { ...fallbackAppSettingsSnapshot, analyticsEnabled: value.analyticsEnabled }
306
+ return fallbackAppSettingsSnapshot
307
+ },
308
+ writePatch: async (patch: AppSettingsPatch) => {
309
+ if (appSettings?.writePatch) return await appSettings.writePatch(patch)
310
+ if (appSettings && patch.analyticsEnabled !== undefined && Object.keys(patch).length === 1) {
311
+ return await appSettings.write({ analyticsEnabled: patch.analyticsEnabled })
312
+ }
313
+ fallbackAppSettingsSnapshot = mergeAppSettingsPatch(appSettings?.getSnapshot() ?? fallbackAppSettingsSnapshot, patch)
314
+ return fallbackAppSettingsSnapshot
315
+ },
316
+ onChange: (listener: (snapshot: AppSettingsSnapshot) => void) => appSettings?.onChange?.(listener) ?? (() => {}),
240
317
  }
241
318
  const resolvedAnalytics = analytics ?? NoopAnalyticsReporter
242
319
 
@@ -307,6 +384,9 @@ export function createWsRouter({
307
384
  if (topic.type === "keybindings") {
308
385
  return Boolean(filter.includeKeybindings)
309
386
  }
387
+ if (topic.type === "app-settings") {
388
+ return Boolean(filter.includeAppSettings)
389
+ }
310
390
  if (topic.type === "chat") {
311
391
  return filter.chatIds?.has(topic.chatId) ?? false
312
392
  }
@@ -398,6 +478,18 @@ export function createWsRouter({
398
478
  }
399
479
  }
400
480
 
481
+ if (topic.type === "app-settings") {
482
+ return {
483
+ v: PROTOCOL_VERSION,
484
+ type: "snapshot",
485
+ id,
486
+ snapshot: {
487
+ type: "app-settings",
488
+ data: resolvedAppSettings.getSnapshot(),
489
+ },
490
+ }
491
+ }
492
+
401
493
  if (topic.type === "update") {
402
494
  return {
403
495
  v: PROTOCOL_VERSION,
@@ -683,6 +775,21 @@ export function createWsRouter({
683
775
  }
684
776
  })
685
777
 
778
+ const disposeAppSettingsEvents = resolvedAppSettings.onChange(() => {
779
+ for (const ws of sockets) {
780
+ const snapshotSignatures = ensureSnapshotSignatures(ws)
781
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
782
+ if (topic.type !== "app-settings") continue
783
+ const envelope = createEnvelope(id, topic)
784
+ if (envelope.type !== "snapshot") continue
785
+ const signature = JSON.stringify(envelope.snapshot)
786
+ if (snapshotSignatures.get(id) === signature) continue
787
+ snapshotSignatures.set(id, signature)
788
+ send(ws, envelope)
789
+ }
790
+ }
791
+ })
792
+
686
793
  const disposeUpdateEvents = updateManager?.onChange(() => {
687
794
  for (const ws of sockets) {
688
795
  const snapshotSignatures = ensureSnapshotSignatures(ws)
@@ -770,6 +877,18 @@ export function createWsRouter({
770
877
  }
771
878
  return
772
879
  }
880
+ case "settings.writeAppSettingsPatch": {
881
+ const previousAnalyticsEnabled = resolvedAppSettings.getSnapshot().analyticsEnabled
882
+ const snapshot = await resolvedAppSettings.writePatch(command.patch)
883
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
884
+ if (command.patch.analyticsEnabled !== undefined && previousAnalyticsEnabled && !snapshot.analyticsEnabled) {
885
+ resolvedAnalytics.track("analytics_disabled")
886
+ }
887
+ if (command.patch.analyticsEnabled !== undefined && !previousAnalyticsEnabled && snapshot.analyticsEnabled) {
888
+ resolvedAnalytics.track("analytics_enabled")
889
+ }
890
+ return
891
+ }
773
892
  case "settings.readLlmProvider": {
774
893
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: await resolvedLlmProvider.read() })
775
894
  return
@@ -1246,6 +1365,7 @@ export function createWsRouter({
1246
1365
  agent.setBackgroundErrorReporter?.(null)
1247
1366
  disposeTerminalEvents()
1248
1367
  disposeKeybindingEvents()
1368
+ disposeAppSettingsEvents()
1249
1369
  disposeUpdateEvents()
1250
1370
  },
1251
1371
  }
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  AppSettingsSnapshot,
3
+ AppSettingsPatch,
3
4
  AgentProvider,
4
5
  ChatAttachment,
5
6
  ChatDiffSnapshot,
@@ -14,9 +15,10 @@ import type {
14
15
  StandaloneTranscriptAttachmentMode,
15
16
  StandaloneTranscriptExportResult,
16
17
  UpdateSnapshot,
18
+ EditorPreset,
17
19
  } from "./types"
18
20
 
19
- export type EditorPreset = "cursor" | "vscode" | "xcode" | "windsurf" | "custom"
21
+ export type { EditorPreset }
20
22
 
21
23
  export interface EditorOpenSettings {
22
24
  preset: EditorPreset
@@ -28,6 +30,7 @@ export type SubscriptionTopic =
28
30
  | { type: "local-projects" }
29
31
  | { type: "update" }
30
32
  | { type: "keybindings" }
33
+ | { type: "app-settings" }
31
34
  | { type: "chat"; chatId: string; recentLimit?: number }
32
35
  | { type: "project-git"; projectId: string }
33
36
  | { type: "terminal"; terminalId: string }
@@ -63,6 +66,7 @@ export type ClientCommand =
63
66
  | { type: "settings.writeKeybindings"; bindings: KeybindingsSnapshot["bindings"] }
64
67
  | { type: "settings.readAppSettings" }
65
68
  | { type: "settings.writeAppSettings"; analyticsEnabled: boolean }
69
+ | { type: "settings.writeAppSettingsPatch"; patch: AppSettingsPatch }
66
70
  | { type: "settings.readLlmProvider" }
67
71
  | {
68
72
  type: "settings.writeLlmProvider"
@@ -81,7 +85,7 @@ export type ClientCommand =
81
85
  | {
82
86
  type: "system.openExternal"
83
87
  localPath: string
84
- action: "open_finder" | "open_terminal" | "open_editor"
88
+ action: "open_finder" | "open_terminal" | "open_editor" | "open_preview" | "open_default"
85
89
  line?: number
86
90
  column?: number
87
91
  editor?: EditorOpenSettings
@@ -208,6 +212,8 @@ export type ClientCommand =
208
212
  | { type: "terminal.resize"; terminalId: string; cols: number; rows: number }
209
213
  | { type: "terminal.close"; terminalId: string }
210
214
 
215
+ export type OpenExternalAction = Extract<ClientCommand, { type: "system.openExternal" }>["action"]
216
+
211
217
  export type ClientEnvelope =
212
218
  | { v: 1; type: "subscribe"; id: string; topic: SubscriptionTopic }
213
219
  | { v: 1; type: "unsubscribe"; id: string }
@@ -3,6 +3,11 @@ export const PROTOCOL_VERSION = 1 as const
3
3
 
4
4
  export type AgentProvider = "claude" | "codex"
5
5
  export type LlmProviderKind = "openai" | "openrouter" | "custom"
6
+ export type AppThemePreference = "light" | "dark" | "system"
7
+ export type ChatSoundPreference = "never" | "unfocused" | "always"
8
+ export type ChatSoundId = "blow" | "bottle" | "frog" | "funk" | "glass" | "ping" | "pop" | "purr" | "tink"
9
+ export type DefaultProviderPreference = "last_used" | AgentProvider
10
+ export type EditorPreset = "cursor" | "vscode" | "xcode" | "windsurf" | "custom"
6
11
  export const DEFAULT_OPENAI_SDK_MODEL = "gpt-5.4-mini"
7
12
  export const DEFAULT_OPENROUTER_SDK_MODEL = "moonshotai/kimi-k2.5:nitro"
8
13
 
@@ -132,6 +137,17 @@ export interface ProviderModelOptionsByProvider {
132
137
  codex: CodexModelOptions
133
138
  }
134
139
 
140
+ export interface ProviderPreference<TModelOptions> {
141
+ model: string
142
+ modelOptions: TModelOptions
143
+ planMode: boolean
144
+ }
145
+
146
+ export type ChatProviderPreferences = {
147
+ claude: ProviderPreference<ClaudeModelOptions>
148
+ codex: ProviderPreference<CodexModelOptions>
149
+ }
150
+
135
151
  export type ModelOptions = Partial<{
136
152
  [K in AgentProvider]: Partial<ProviderModelOptionsByProvider[K]>
137
153
  }>
@@ -354,10 +370,39 @@ export interface LocalProjectsSnapshot {
354
370
 
355
371
  export interface AppSettingsSnapshot {
356
372
  analyticsEnabled: boolean
373
+ browserSettingsMigrated: boolean
374
+ theme: AppThemePreference
375
+ chatSoundPreference: ChatSoundPreference
376
+ chatSoundId: ChatSoundId
377
+ terminal: {
378
+ scrollbackLines: number
379
+ minColumnWidth: number
380
+ }
381
+ editor: {
382
+ preset: EditorPreset
383
+ commandTemplate: string
384
+ }
385
+ defaultProvider: DefaultProviderPreference
386
+ providerDefaults: ChatProviderPreferences
357
387
  warning: string | null
358
388
  filePathDisplay: string
359
389
  }
360
390
 
391
+ export interface AppSettingsPatch {
392
+ analyticsEnabled?: boolean
393
+ browserSettingsMigrated?: boolean
394
+ theme?: AppThemePreference
395
+ chatSoundPreference?: ChatSoundPreference
396
+ chatSoundId?: ChatSoundId
397
+ terminal?: Partial<AppSettingsSnapshot["terminal"]>
398
+ editor?: Partial<AppSettingsSnapshot["editor"]>
399
+ defaultProvider?: DefaultProviderPreference
400
+ providerDefaults?: {
401
+ claude?: Partial<ProviderPreference<ClaudeModelOptions>>
402
+ codex?: Partial<ProviderPreference<CodexModelOptions>>
403
+ }
404
+ }
405
+
361
406
  export interface LlmProviderFile {
362
407
  provider?: LlmProviderKind
363
408
  apiKey?: string