kanna-code 0.33.9 → 0.34.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.
@@ -1,5 +1,8 @@
1
1
  import { describe, expect, test } from "bun:test"
2
- import type { KeybindingsSnapshot, LlmProviderSnapshot, UpdateSnapshot } from "../shared/types"
2
+ import { mkdtemp, rm } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import path from "node:path"
5
+ import type { AppSettingsSnapshot, KeybindingsSnapshot, LlmProviderSnapshot, UpdateSnapshot } from "../shared/types"
3
6
  import { PROTOCOL_VERSION } from "../shared/types"
4
7
  import { createEmptyState } from "./events"
5
8
  import { createWsRouter } from "./ws-router"
@@ -56,6 +59,12 @@ const DEFAULT_KEYBINDINGS_SNAPSHOT: KeybindingsSnapshot = {
56
59
  filePathDisplay: "~/.kanna/keybindings.json",
57
60
  }
58
61
 
62
+ const DEFAULT_APP_SETTINGS_SNAPSHOT: AppSettingsSnapshot = {
63
+ analyticsEnabled: true,
64
+ warning: null,
65
+ filePathDisplay: "~/.kanna/data/settings.json",
66
+ }
67
+
59
68
  const DEFAULT_UPDATE_SNAPSHOT: UpdateSnapshot = {
60
69
  currentVersion: "0.12.0",
61
70
  latestVersion: null,
@@ -211,6 +220,258 @@ describe("ws-router", () => {
211
220
  }])
212
221
  })
213
222
 
223
+ test("reads and writes app settings via commands", async () => {
224
+ const writes: Array<{ analyticsEnabled: boolean }> = []
225
+ let analyticsEnabled = DEFAULT_APP_SETTINGS_SNAPSHOT.analyticsEnabled
226
+ const router = createWsRouter({
227
+ store: { state: createEmptyState() } as never,
228
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
229
+ terminals: {
230
+ getSnapshot: () => null,
231
+ onEvent: () => () => {},
232
+ } as never,
233
+ keybindings: {
234
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
235
+ onChange: () => () => {},
236
+ } as never,
237
+ appSettings: {
238
+ getSnapshot: () => ({
239
+ ...DEFAULT_APP_SETTINGS_SNAPSHOT,
240
+ analyticsEnabled,
241
+ }),
242
+ write: async (value) => {
243
+ writes.push(value)
244
+ analyticsEnabled = value.analyticsEnabled
245
+ return {
246
+ ...DEFAULT_APP_SETTINGS_SNAPSHOT,
247
+ analyticsEnabled: value.analyticsEnabled,
248
+ }
249
+ },
250
+ },
251
+ refreshDiscovery: async () => [],
252
+ getDiscoveredProjects: () => [],
253
+ machineDisplayName: "Local Machine",
254
+ updateManager: null,
255
+ })
256
+ const ws = new FakeWebSocket()
257
+
258
+ await router.handleMessage(
259
+ ws as never,
260
+ JSON.stringify({
261
+ v: 1,
262
+ type: "command",
263
+ id: "settings-read-1",
264
+ command: { type: "settings.readAppSettings" },
265
+ })
266
+ )
267
+
268
+ await router.handleMessage(
269
+ ws as never,
270
+ JSON.stringify({
271
+ v: 1,
272
+ type: "command",
273
+ id: "settings-write-1",
274
+ command: {
275
+ type: "settings.writeAppSettings",
276
+ analyticsEnabled: false,
277
+ },
278
+ })
279
+ )
280
+
281
+ expect(ws.sent).toEqual([
282
+ {
283
+ v: PROTOCOL_VERSION,
284
+ type: "ack",
285
+ id: "settings-read-1",
286
+ result: DEFAULT_APP_SETTINGS_SNAPSHOT,
287
+ },
288
+ {
289
+ v: PROTOCOL_VERSION,
290
+ type: "ack",
291
+ id: "settings-write-1",
292
+ result: {
293
+ ...DEFAULT_APP_SETTINGS_SNAPSHOT,
294
+ analyticsEnabled: false,
295
+ },
296
+ },
297
+ ])
298
+ expect(writes).toEqual([{ analyticsEnabled: false }])
299
+ })
300
+
301
+ test("tracks analytics preference transitions in the correct order", async () => {
302
+ const analyticsEvents: string[] = []
303
+ let analyticsEnabled = true
304
+ const router = createWsRouter({
305
+ store: { state: createEmptyState() } as never,
306
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
307
+ terminals: {
308
+ getSnapshot: () => null,
309
+ onEvent: () => () => {},
310
+ } as never,
311
+ keybindings: {
312
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
313
+ onChange: () => () => {},
314
+ } as never,
315
+ appSettings: {
316
+ getSnapshot: () => ({
317
+ ...DEFAULT_APP_SETTINGS_SNAPSHOT,
318
+ analyticsEnabled,
319
+ }),
320
+ write: async (value) => {
321
+ analyticsEnabled = value.analyticsEnabled
322
+ return {
323
+ ...DEFAULT_APP_SETTINGS_SNAPSHOT,
324
+ analyticsEnabled: value.analyticsEnabled,
325
+ }
326
+ },
327
+ },
328
+ analytics: {
329
+ track: (eventName: string) => {
330
+ analyticsEvents.push(eventName)
331
+ },
332
+ trackLaunch: () => {},
333
+ },
334
+ refreshDiscovery: async () => [],
335
+ getDiscoveredProjects: () => [],
336
+ machineDisplayName: "Local Machine",
337
+ updateManager: null,
338
+ })
339
+ const ws = new FakeWebSocket()
340
+
341
+ await router.handleMessage(
342
+ ws as never,
343
+ JSON.stringify({
344
+ v: 1,
345
+ type: "command",
346
+ id: "settings-disable-1",
347
+ command: {
348
+ type: "settings.writeAppSettings",
349
+ analyticsEnabled: false,
350
+ },
351
+ })
352
+ )
353
+
354
+ await router.handleMessage(
355
+ ws as never,
356
+ JSON.stringify({
357
+ v: 1,
358
+ type: "command",
359
+ id: "settings-enable-1",
360
+ command: {
361
+ type: "settings.writeAppSettings",
362
+ analyticsEnabled: true,
363
+ },
364
+ })
365
+ )
366
+
367
+ await router.handleMessage(
368
+ ws as never,
369
+ JSON.stringify({
370
+ v: 1,
371
+ type: "command",
372
+ id: "settings-enable-2",
373
+ command: {
374
+ type: "settings.writeAppSettings",
375
+ analyticsEnabled: true,
376
+ },
377
+ })
378
+ )
379
+
380
+ expect(analyticsEvents).toEqual([
381
+ "analytics_disabled",
382
+ "analytics_enabled",
383
+ ])
384
+ })
385
+
386
+ test("tracks project lifecycle analytics", async () => {
387
+ const analyticsEvents: string[] = []
388
+ const state = createEmptyState()
389
+ const projectPath = await mkdtemp(path.join(tmpdir(), "kanna-router-project-"))
390
+
391
+ try {
392
+ const router = createWsRouter({
393
+ store: {
394
+ state,
395
+ openProject: async (localPath: string, title?: string) => {
396
+ const project = {
397
+ id: "project-1",
398
+ localPath,
399
+ title: title ?? "Project",
400
+ createdAt: Date.now(),
401
+ updatedAt: Date.now(),
402
+ deletedAt: null,
403
+ }
404
+ state.projectsById.set(project.id, project as never)
405
+ state.projectIdsByPath.set(localPath, project.id)
406
+ return project
407
+ },
408
+ getProject: () => ({
409
+ id: "project-1",
410
+ localPath: projectPath,
411
+ }),
412
+ listChatsByProject: () => [{ id: "chat-1" }, { id: "chat-2" }],
413
+ removeProject: async () => {},
414
+ } as never,
415
+ agent: {
416
+ cancel: async () => {},
417
+ closeChat: async () => {},
418
+ getActiveStatuses: () => new Map(),
419
+ getDrainingChatIds: () => new Set(),
420
+ } as never,
421
+ analytics: {
422
+ track: (eventName: string) => {
423
+ analyticsEvents.push(eventName)
424
+ },
425
+ trackLaunch: () => {},
426
+ },
427
+ terminals: {
428
+ closeByCwd: () => {},
429
+ getSnapshot: () => null,
430
+ onEvent: () => () => {},
431
+ } as never,
432
+ keybindings: {
433
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
434
+ onChange: () => () => {},
435
+ } as never,
436
+ refreshDiscovery: async () => [],
437
+ getDiscoveredProjects: () => [],
438
+ machineDisplayName: "Local Machine",
439
+ updateManager: null,
440
+ })
441
+ const ws = new FakeWebSocket()
442
+
443
+ await router.handleMessage(
444
+ ws as never,
445
+ JSON.stringify({
446
+ v: 1,
447
+ type: "command",
448
+ id: "project-create-1",
449
+ command: { type: "project.create", localPath: projectPath, title: "Project" },
450
+ })
451
+ )
452
+
453
+ await router.handleMessage(
454
+ ws as never,
455
+ JSON.stringify({
456
+ v: 1,
457
+ type: "command",
458
+ id: "project-remove-1",
459
+ command: { type: "project.remove", projectId: "project-1" },
460
+ })
461
+ )
462
+
463
+ expect(analyticsEvents).toEqual([
464
+ "project_opened",
465
+ "project_created",
466
+ "project_removed",
467
+ "chat_deleted",
468
+ "chat_deleted",
469
+ ])
470
+ } finally {
471
+ await rm(projectPath, { recursive: true, force: true })
472
+ }
473
+ })
474
+
214
475
  test("acks terminal.input without rebroadcasting terminal snapshots", async () => {
215
476
  const router = createWsRouter({
216
477
  store: { state: createEmptyState() } as never,
@@ -3,17 +3,19 @@ import { PROTOCOL_VERSION } from "../shared/types"
3
3
  import type { ClientEnvelope, ServerEnvelope, SubscriptionTopic } from "../shared/protocol"
4
4
  import { isClientEnvelope } from "../shared/protocol"
5
5
  import type { AgentCoordinator } from "./agent"
6
+ import type { AnalyticsReporter } from "./analytics"
7
+ import { NoopAnalyticsReporter } from "./analytics"
8
+ import type { AppSettingsManager } from "./app-settings"
6
9
  import type { DiscoveredProject } from "./discovery"
7
10
  import { DiffStore } from "./diff-store"
8
11
  import { EventStore } from "./event-store"
9
12
  import { openExternal } from "./external-open"
10
13
  import { KeybindingsManager } from "./keybindings"
11
- import { ensureProjectDirectory } from "./paths"
14
+ import { ensureProjectDirectory, resolveLocalPath } from "./paths"
12
15
  import { TerminalManager } from "./terminal-manager"
13
16
  import type { UpdateManager } from "./update-manager"
14
17
  import { deriveChatSnapshot, deriveLocalProjectsSnapshot, deriveSidebarData } from "./read-models"
15
- import type { LlmProviderSnapshot } from "../shared/types"
16
- import type { LlmProviderValidationResult } from "../shared/types"
18
+ import type { AppSettingsSnapshot, LlmProviderSnapshot, LlmProviderValidationResult } from "../shared/types"
17
19
 
18
20
  const DEFAULT_CHAT_RECENT_LIMIT = 200
19
21
 
@@ -98,6 +100,8 @@ interface CreateWsRouterArgs {
98
100
  agent: AgentCoordinator
99
101
  terminals: TerminalManager
100
102
  keybindings: KeybindingsManager
103
+ appSettings?: Pick<AppSettingsManager, "getSnapshot" | "write">
104
+ analytics?: AnalyticsReporter
101
105
  llmProvider?: {
102
106
  read: () => Promise<LlmProviderSnapshot>
103
107
  write: (value: Pick<LlmProviderSnapshot, "provider" | "apiKey" | "model" | "baseUrl">) => Promise<LlmProviderSnapshot>
@@ -152,6 +156,8 @@ export function createWsRouter({
152
156
  agent,
153
157
  terminals,
154
158
  keybindings,
159
+ appSettings,
160
+ analytics,
155
161
  llmProvider,
156
162
  refreshDiscovery,
157
163
  getDiscoveredProjects,
@@ -219,6 +225,19 @@ export function createWsRouter({
219
225
  },
220
226
  }),
221
227
  }
228
+ const resolvedAppSettings = appSettings ?? {
229
+ getSnapshot: () => ({
230
+ analyticsEnabled: true,
231
+ warning: null,
232
+ filePathDisplay: "~/.kanna/data/settings.json",
233
+ } satisfies AppSettingsSnapshot),
234
+ write: async ({ analyticsEnabled }: { analyticsEnabled: boolean }) => ({
235
+ analyticsEnabled,
236
+ warning: null,
237
+ filePathDisplay: "~/.kanna/data/settings.json",
238
+ } satisfies AppSettingsSnapshot),
239
+ }
240
+ const resolvedAnalytics = analytics ?? NoopAnalyticsReporter
222
241
 
223
242
  function getProtectedChatIds() {
224
243
  const activeStatuses = agent.getActiveStatuses()
@@ -734,6 +753,22 @@ export function createWsRouter({
734
753
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
735
754
  return
736
755
  }
756
+ case "settings.readAppSettings": {
757
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: resolvedAppSettings.getSnapshot() })
758
+ return
759
+ }
760
+ case "settings.writeAppSettings": {
761
+ const previousAnalyticsEnabled = resolvedAppSettings.getSnapshot().analyticsEnabled
762
+ if (previousAnalyticsEnabled && !command.analyticsEnabled) {
763
+ resolvedAnalytics.track("analytics_disabled")
764
+ }
765
+ const snapshot = await resolvedAppSettings.write({ analyticsEnabled: command.analyticsEnabled })
766
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
767
+ if (!previousAnalyticsEnabled && command.analyticsEnabled) {
768
+ resolvedAnalytics.track("analytics_enabled")
769
+ }
770
+ return
771
+ }
737
772
  case "settings.readLlmProvider": {
738
773
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: await resolvedLlmProvider.read() })
739
774
  return
@@ -760,21 +795,33 @@ export function createWsRouter({
760
795
  }
761
796
  case "project.open": {
762
797
  await ensureProjectDirectory(command.localPath)
798
+ const normalizedPath = resolveLocalPath(command.localPath)
799
+ const existingProjectId = store.state.projectIdsByPath.get(normalizedPath)
763
800
  const project = await store.openProject(command.localPath)
764
801
  await refreshDiscovery()
765
802
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { projectId: project.id } })
803
+ if (!existingProjectId) {
804
+ resolvedAnalytics.track("project_opened")
805
+ }
766
806
  break
767
807
  }
768
808
  case "project.create": {
769
809
  await ensureProjectDirectory(command.localPath)
810
+ const normalizedPath = resolveLocalPath(command.localPath)
811
+ const existingProjectId = store.state.projectIdsByPath.get(normalizedPath)
770
812
  const project = await store.openProject(command.localPath, command.title)
771
813
  await refreshDiscovery()
772
814
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { projectId: project.id } })
815
+ if (!existingProjectId) {
816
+ resolvedAnalytics.track("project_opened")
817
+ resolvedAnalytics.track("project_created")
818
+ }
773
819
  break
774
820
  }
775
821
  case "project.remove": {
776
822
  const project = store.getProject(command.projectId)
777
- for (const chat of store.listChatsByProject(command.projectId)) {
823
+ const chats = store.listChatsByProject(command.projectId)
824
+ for (const chat of chats) {
778
825
  await agent.cancel(chat.id)
779
826
  await agent.closeChat(chat.id)
780
827
  }
@@ -783,6 +830,10 @@ export function createWsRouter({
783
830
  }
784
831
  await store.removeProject(command.projectId)
785
832
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
833
+ resolvedAnalytics.track("project_removed")
834
+ for (const _chat of chats) {
835
+ resolvedAnalytics.track("chat_deleted")
836
+ }
786
837
  break
787
838
  }
788
839
  case "sidebar.reorderProjectGroups": {
@@ -811,6 +862,7 @@ export function createWsRouter({
811
862
  case "chat.create": {
812
863
  const chat = await store.createChat(command.projectId)
813
864
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { chatId: chat.id } })
865
+ resolvedAnalytics.track("chat_created")
814
866
  await broadcastChatAndSidebar(chat.id)
815
867
  return
816
868
  }
@@ -831,6 +883,7 @@ export function createWsRouter({
831
883
  await agent.closeChat(command.chatId)
832
884
  await store.deleteChat(command.chatId)
833
885
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
886
+ resolvedAnalytics.track("chat_deleted")
834
887
  await broadcastFilteredSnapshots({ includeSidebar: true })
835
888
  return
836
889
  }
@@ -0,0 +1,30 @@
1
+ export const ANALYTICS_ENDPOINT = "https://kanna.sh/api/t"
2
+
3
+ export const ANALYTICS_STATIC_EVENT_NAMES = [
4
+ "app_launch",
5
+ "project_opened",
6
+ "project_created",
7
+ "project_removed",
8
+ "chat_created",
9
+ "chat_deleted",
10
+ "message_sent",
11
+ "update_checked",
12
+ "update_installed",
13
+ "update_failed",
14
+ "analytics_enabled",
15
+ "analytics_disabled",
16
+ ] as const
17
+
18
+ export const ANALYTICS_STATIC_PROPERTY_NAMES = [
19
+ "current_version",
20
+ "environment",
21
+ "latest_version",
22
+ "custom_port_enabled",
23
+ "no_open_enabled",
24
+ "password_enabled",
25
+ "strict_port_enabled",
26
+ "remote_enabled",
27
+ "host_enabled",
28
+ "share_quick_enabled",
29
+ "share_token_enabled",
30
+ ] as const
@@ -47,6 +47,14 @@ export function getDataDirDisplay(env: RuntimeEnv = getRuntimeEnv()) {
47
47
  return `${getDataRootDirDisplay(env)}/data`
48
48
  }
49
49
 
50
+ export function getSettingsFilePath(homeDir: string, env: RuntimeEnv = getRuntimeEnv()) {
51
+ return `${getDataDir(homeDir, env)}/settings.json`
52
+ }
53
+
54
+ export function getSettingsFilePathDisplay(env: RuntimeEnv = getRuntimeEnv()) {
55
+ return `${getDataDirDisplay(env)}/settings.json`
56
+ }
57
+
50
58
  export function getKeybindingsFilePath(homeDir: string, env: RuntimeEnv = getRuntimeEnv()) {
51
59
  return `${getDataRootDir(homeDir, env)}/keybindings.json`
52
60
  }
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ AppSettingsSnapshot,
2
3
  AgentProvider,
3
4
  ChatAttachment,
4
5
  ChatDiffSnapshot,
@@ -58,6 +59,8 @@ export type ClientCommand =
58
59
  | { type: "update.install" }
59
60
  | { type: "settings.readKeybindings" }
60
61
  | { type: "settings.writeKeybindings"; bindings: KeybindingsSnapshot["bindings"] }
62
+ | { type: "settings.readAppSettings" }
63
+ | { type: "settings.writeAppSettings"; analyticsEnabled: boolean }
61
64
  | { type: "settings.readLlmProvider" }
62
65
  | {
63
66
  type: "settings.writeLlmProvider"
@@ -207,6 +210,7 @@ export type ServerSnapshot =
207
210
  | { type: "local-projects"; data: LocalProjectsSnapshot }
208
211
  | { type: "update"; data: UpdateSnapshot }
209
212
  | { type: "keybindings"; data: KeybindingsSnapshot }
213
+ | { type: "app-settings"; data: AppSettingsSnapshot }
210
214
  | { type: "llm-provider"; data: LlmProviderSnapshot }
211
215
  | { type: "chat"; data: ChatSnapshot | null }
212
216
  | { type: "project-git"; data: ChatDiffSnapshot | null }
@@ -308,6 +308,12 @@ export interface LocalProjectsSnapshot {
308
308
  projects: LocalProjectSummary[]
309
309
  }
310
310
 
311
+ export interface AppSettingsSnapshot {
312
+ analyticsEnabled: boolean
313
+ warning: string | null
314
+ filePathDisplay: string
315
+ }
316
+
311
317
  export interface LlmProviderFile {
312
318
  provider?: LlmProviderKind
313
319
  apiKey?: string