kanna-code 0.31.0 → 0.32.0

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,9 +1,33 @@
1
1
  import { describe, expect, test } from "bun:test"
2
- import type { KeybindingsSnapshot, UpdateSnapshot } from "../shared/types"
2
+ import type { KeybindingsSnapshot, LlmProviderSnapshot, UpdateSnapshot } from "../shared/types"
3
3
  import { PROTOCOL_VERSION } from "../shared/types"
4
4
  import { createEmptyState } from "./events"
5
5
  import { createWsRouter } from "./ws-router"
6
6
 
7
+ function withSidebarGroupDefaults(group: {
8
+ groupKey: string
9
+ localPath: string
10
+ chats: Array<{
11
+ _id: string
12
+ _creationTime: number
13
+ chatId: string
14
+ title: string
15
+ status: "idle" | "starting" | "running" | "waiting_for_user" | "failed"
16
+ unread: boolean
17
+ localPath: string
18
+ provider: "claude" | "codex" | null
19
+ lastMessageAt?: number
20
+ hasAutomation: boolean
21
+ }>
22
+ }) {
23
+ return {
24
+ ...group,
25
+ previewChats: group.chats,
26
+ olderChats: [],
27
+ defaultCollapsed: true,
28
+ }
29
+ }
30
+
7
31
  class FakeWebSocket {
8
32
  readonly sent: unknown[] = []
9
33
  readonly data = {
@@ -42,6 +66,17 @@ const DEFAULT_UPDATE_SNAPSHOT: UpdateSnapshot = {
42
66
  reloadRequestedAt: null,
43
67
  }
44
68
 
69
+ const DEFAULT_LLM_PROVIDER_SNAPSHOT: LlmProviderSnapshot = {
70
+ provider: "openai",
71
+ apiKey: "",
72
+ model: "",
73
+ baseUrl: "",
74
+ resolvedBaseUrl: "https://api.openai.com/v1",
75
+ enabled: false,
76
+ warning: null,
77
+ filePathDisplay: "~/.kanna/llm-provider.json",
78
+ }
79
+
45
80
  describe("ws-router", () => {
46
81
  test("acks system.ping without broadcasting snapshots", async () => {
47
82
  const router = createWsRouter({
@@ -83,6 +118,94 @@ describe("ws-router", () => {
83
118
  ])
84
119
  })
85
120
 
121
+ test("reads and writes llm provider settings via commands", async () => {
122
+ const writes: Array<Pick<LlmProviderSnapshot, "provider" | "apiKey" | "model" | "baseUrl">> = []
123
+ const router = createWsRouter({
124
+ store: { state: createEmptyState() } as never,
125
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
126
+ terminals: {
127
+ getSnapshot: () => null,
128
+ onEvent: () => () => {},
129
+ } as never,
130
+ keybindings: {
131
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
132
+ onChange: () => () => {},
133
+ } as never,
134
+ llmProvider: {
135
+ read: async () => DEFAULT_LLM_PROVIDER_SNAPSHOT,
136
+ write: async (value) => {
137
+ writes.push(value)
138
+ return {
139
+ ...DEFAULT_LLM_PROVIDER_SNAPSHOT,
140
+ ...value,
141
+ resolvedBaseUrl: value.provider === "custom" ? value.baseUrl : "https://api.openai.com/v1",
142
+ enabled: Boolean(value.apiKey && value.model),
143
+ }
144
+ },
145
+ },
146
+ refreshDiscovery: async () => [],
147
+ getDiscoveredProjects: () => [],
148
+ machineDisplayName: "Local Machine",
149
+ updateManager: null,
150
+ })
151
+ const ws = new FakeWebSocket()
152
+
153
+ await router.handleMessage(
154
+ ws as never,
155
+ JSON.stringify({
156
+ v: 1,
157
+ type: "command",
158
+ id: "llm-read-1",
159
+ command: { type: "settings.readLlmProvider" },
160
+ })
161
+ )
162
+
163
+ await router.handleMessage(
164
+ ws as never,
165
+ JSON.stringify({
166
+ v: 1,
167
+ type: "command",
168
+ id: "llm-write-1",
169
+ command: {
170
+ type: "settings.writeLlmProvider",
171
+ provider: "custom",
172
+ apiKey: "test-key",
173
+ model: "gpt-test",
174
+ baseUrl: "https://example.com/v1",
175
+ },
176
+ })
177
+ )
178
+
179
+ expect(ws.sent).toEqual([
180
+ {
181
+ v: PROTOCOL_VERSION,
182
+ type: "ack",
183
+ id: "llm-read-1",
184
+ result: DEFAULT_LLM_PROVIDER_SNAPSHOT,
185
+ },
186
+ {
187
+ v: PROTOCOL_VERSION,
188
+ type: "ack",
189
+ id: "llm-write-1",
190
+ result: {
191
+ ...DEFAULT_LLM_PROVIDER_SNAPSHOT,
192
+ provider: "custom",
193
+ apiKey: "test-key",
194
+ model: "gpt-test",
195
+ baseUrl: "https://example.com/v1",
196
+ resolvedBaseUrl: "https://example.com/v1",
197
+ enabled: true,
198
+ },
199
+ },
200
+ ])
201
+ expect(writes).toEqual([{
202
+ provider: "custom",
203
+ apiKey: "test-key",
204
+ model: "gpt-test",
205
+ baseUrl: "https://example.com/v1",
206
+ }])
207
+ })
208
+
86
209
  test("acks terminal.input without rebroadcasting terminal snapshots", async () => {
87
210
  const router = createWsRouter({
88
211
  store: { state: createEmptyState() } as never,
@@ -183,6 +306,54 @@ describe("ws-router", () => {
183
306
  })
184
307
  })
185
308
 
309
+ test("reuses one sidebar derivation across sockets in the same broadcast pass", async () => {
310
+ const state = createEmptyState()
311
+ state.projectsById.set("project-1", {
312
+ id: "project-1",
313
+ localPath: "/tmp/project",
314
+ title: "Project",
315
+ createdAt: 1,
316
+ updatedAt: 1,
317
+ })
318
+
319
+ let activeStatusCalls = 0
320
+ const router = createWsRouter({
321
+ store: { state } as never,
322
+ agent: {
323
+ getActiveStatuses: () => {
324
+ activeStatusCalls += 1
325
+ return new Map()
326
+ },
327
+ getDrainingChatIds: () => new Set(),
328
+ } as never,
329
+ terminals: {
330
+ getSnapshot: () => null,
331
+ onEvent: () => () => {},
332
+ } as never,
333
+ keybindings: {
334
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
335
+ onChange: () => () => {},
336
+ } as never,
337
+ refreshDiscovery: async () => [],
338
+ getDiscoveredProjects: () => [],
339
+ machineDisplayName: "Local Machine",
340
+ updateManager: null,
341
+ })
342
+
343
+ const wsA = new FakeWebSocket()
344
+ const wsB = new FakeWebSocket()
345
+ router.handleOpen(wsA as never)
346
+ router.handleOpen(wsB as never)
347
+ wsA.data.subscriptions.set("sidebar-a", { type: "sidebar" })
348
+ wsB.data.subscriptions.set("sidebar-b", { type: "sidebar" })
349
+
350
+ await router.broadcastSnapshots()
351
+
352
+ expect(activeStatusCalls).toBe(1)
353
+ expect(wsA.sent).toHaveLength(1)
354
+ expect(wsB.sent).toHaveLength(1)
355
+ })
356
+
186
357
  test("subscribes to project git snapshots independently from chat snapshots", async () => {
187
358
  const state = createEmptyState()
188
359
  state.projectsById.set("project-1", {
@@ -625,7 +796,7 @@ describe("ws-router", () => {
625
796
  snapshot: {
626
797
  type: "sidebar",
627
798
  data: {
628
- projectGroups: [{
799
+ projectGroups: [withSidebarGroupDefaults({
629
800
  groupKey: "project-1",
630
801
  localPath: "/tmp/project",
631
802
  chats: [{
@@ -637,10 +808,9 @@ describe("ws-router", () => {
637
808
  unread: false,
638
809
  localPath: "/tmp/project",
639
810
  provider: null,
640
- lastMessageAt: undefined,
641
811
  hasAutomation: false,
642
812
  }],
643
- }],
813
+ })],
644
814
  },
645
815
  },
646
816
  })
@@ -651,7 +821,7 @@ describe("ws-router", () => {
651
821
  snapshot: {
652
822
  type: "sidebar",
653
823
  data: {
654
- projectGroups: [{
824
+ projectGroups: [withSidebarGroupDefaults({
655
825
  groupKey: "project-1",
656
826
  localPath: "/tmp/project",
657
827
  chats: [{
@@ -663,10 +833,102 @@ describe("ws-router", () => {
663
833
  unread: false,
664
834
  localPath: "/tmp/project",
665
835
  provider: null,
666
- lastMessageAt: undefined,
667
836
  hasAutomation: false,
668
837
  }],
669
- }],
838
+ })],
839
+ },
840
+ },
841
+ })
842
+ })
843
+
844
+ test("reorders sidebar project groups on the server and rebroadcasts the snapshot", async () => {
845
+ const state = createEmptyState()
846
+ state.projectsById.set("project-1", {
847
+ id: "project-1",
848
+ localPath: "/tmp/project-1",
849
+ title: "Project 1",
850
+ createdAt: 1,
851
+ updatedAt: 1,
852
+ })
853
+ state.projectsById.set("project-2", {
854
+ id: "project-2",
855
+ localPath: "/tmp/project-2",
856
+ title: "Project 2",
857
+ createdAt: 2,
858
+ updatedAt: 2,
859
+ })
860
+
861
+ const setSidebarProjectOrderCalls: string[][] = []
862
+ const router = createWsRouter({
863
+ store: {
864
+ state,
865
+ async setSidebarProjectOrder(projectIds: string[]) {
866
+ setSidebarProjectOrderCalls.push(projectIds)
867
+ state.sidebarProjectOrder = [...projectIds]
868
+ },
869
+ } as never,
870
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
871
+ terminals: {
872
+ getSnapshot: () => null,
873
+ onEvent: () => () => {},
874
+ } as never,
875
+ keybindings: {
876
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
877
+ onChange: () => () => {},
878
+ } as never,
879
+ refreshDiscovery: async () => [],
880
+ getDiscoveredProjects: () => [],
881
+ machineDisplayName: "Local Machine",
882
+ updateManager: null,
883
+ })
884
+ const ws = new FakeWebSocket()
885
+ router.handleOpen(ws as never)
886
+
887
+ await router.handleMessage(
888
+ ws as never,
889
+ JSON.stringify({
890
+ v: 1,
891
+ type: "subscribe",
892
+ id: "sidebar-sub-1",
893
+ topic: { type: "sidebar" },
894
+ })
895
+ )
896
+
897
+ await router.handleMessage(
898
+ ws as never,
899
+ JSON.stringify({
900
+ v: 1,
901
+ type: "command",
902
+ id: "sidebar-reorder-1",
903
+ command: { type: "sidebar.reorderProjectGroups", projectIds: ["project-1", "project-2"] },
904
+ })
905
+ )
906
+
907
+ expect(setSidebarProjectOrderCalls).toEqual([["project-1", "project-2"]])
908
+ expect(ws.sent.at(-2)).toEqual({
909
+ v: PROTOCOL_VERSION,
910
+ type: "ack",
911
+ id: "sidebar-reorder-1",
912
+ })
913
+ expect(ws.sent.at(-1)).toEqual({
914
+ v: PROTOCOL_VERSION,
915
+ type: "snapshot",
916
+ id: "sidebar-sub-1",
917
+ snapshot: {
918
+ type: "sidebar",
919
+ data: {
920
+ projectGroups: [
921
+ withSidebarGroupDefaults({
922
+ groupKey: "project-1",
923
+ localPath: "/tmp/project-1",
924
+ chats: [],
925
+ }),
926
+ withSidebarGroupDefaults({
927
+ groupKey: "project-2",
928
+ localPath: "/tmp/project-2",
929
+ chats: [],
930
+ }),
931
+ ],
670
932
  },
671
933
  },
672
934
  })
@@ -741,9 +1003,11 @@ describe("ws-router", () => {
741
1003
  type: "sidebar",
742
1004
  data: {
743
1005
  projectGroups: [{
744
- groupKey: "project-1",
745
- localPath: "/tmp/project",
746
- chats: [],
1006
+ ...withSidebarGroupDefaults({
1007
+ groupKey: "project-1",
1008
+ localPath: "/tmp/project",
1009
+ chats: [],
1010
+ }),
747
1011
  }],
748
1012
  },
749
1013
  },
@@ -12,6 +12,7 @@ import { ensureProjectDirectory } from "./paths"
12
12
  import { TerminalManager } from "./terminal-manager"
13
13
  import type { UpdateManager } from "./update-manager"
14
14
  import { deriveChatSnapshot, deriveLocalProjectsSnapshot, deriveSidebarData } from "./read-models"
15
+ import type { LlmProviderSnapshot } from "../shared/types"
15
16
 
16
17
  const DEFAULT_CHAT_RECENT_LIMIT = 200
17
18
 
@@ -96,6 +97,10 @@ interface CreateWsRouterArgs {
96
97
  agent: AgentCoordinator
97
98
  terminals: TerminalManager
98
99
  keybindings: KeybindingsManager
100
+ llmProvider?: {
101
+ read: () => Promise<LlmProviderSnapshot>
102
+ write: (value: Pick<LlmProviderSnapshot, "provider" | "apiKey" | "model" | "baseUrl">) => Promise<LlmProviderSnapshot>
103
+ }
99
104
  refreshDiscovery: () => Promise<DiscoveredProject[]>
100
105
  getDiscoveredProjects: () => DiscoveredProject[]
101
106
  machineDisplayName: string
@@ -112,6 +117,13 @@ interface SnapshotBroadcastFilter {
112
117
  terminalIds?: Set<string>
113
118
  }
114
119
 
120
+ interface SnapshotComputationCache {
121
+ sidebar?: {
122
+ data: ReturnType<typeof deriveSidebarData>
123
+ signature: string
124
+ }
125
+ }
126
+
115
127
  function send(ws: ServerWebSocket<ClientState>, message: ServerEnvelope) {
116
128
  const payload = JSON.stringify(message)
117
129
  ws.send(payload)
@@ -132,6 +144,7 @@ export function createWsRouter({
132
144
  agent,
133
145
  terminals,
134
146
  keybindings,
147
+ llmProvider,
135
148
  refreshDiscovery,
136
149
  getDiscoveredProjects,
137
150
  machineDisplayName,
@@ -160,6 +173,37 @@ export function createWsRouter({
160
173
  ignoreFile: async () => ({ snapshotChanged: false }),
161
174
  readPatch: async () => ({ patch: "" }),
162
175
  }
176
+ const resolvedLlmProvider = llmProvider ?? {
177
+ read: async () => ({
178
+ provider: "openai" as const,
179
+ apiKey: "",
180
+ model: "gpt-5.4-mini",
181
+ baseUrl: "",
182
+ resolvedBaseUrl: "https://api.openai.com/v1",
183
+ enabled: false,
184
+ warning: null,
185
+ filePathDisplay: "~/.kanna/llm-provider.json",
186
+ }),
187
+ write: async ({ provider, apiKey, model, baseUrl }: {
188
+ provider: "openai" | "openrouter" | "custom"
189
+ apiKey: string
190
+ model: string
191
+ baseUrl: string
192
+ }) => ({
193
+ provider,
194
+ apiKey,
195
+ model,
196
+ baseUrl,
197
+ resolvedBaseUrl: provider === "openrouter"
198
+ ? "https://openrouter.ai/api/v1"
199
+ : provider === "custom"
200
+ ? baseUrl
201
+ : "https://api.openai.com/v1",
202
+ enabled: false,
203
+ warning: null,
204
+ filePathDisplay: "~/.kanna/llm-provider.json",
205
+ }),
206
+ }
163
207
 
164
208
  function getProtectedChatIds() {
165
209
  const activeStatuses = agent.getActiveStatuses()
@@ -241,28 +285,50 @@ export function createWsRouter({
241
285
  return true
242
286
  }
243
287
 
244
- function createEnvelope(id: string, topic: SubscriptionTopic): ServerEnvelope {
288
+ function getSidebarSnapshotCacheEntry(cache?: SnapshotComputationCache) {
289
+ if (cache?.sidebar) {
290
+ return cache.sidebar
291
+ }
292
+
293
+ const startedAt = performance.now()
294
+ const data = deriveSidebarData(store.state, agent.getActiveStatuses())
295
+ if (isSendToStartingProfilingEnabled()) {
296
+ const totalChats = data.projectGroups.reduce((count, group) => count + group.chats.length, 0)
297
+ console.log("[kanna/send->starting][server]", JSON.stringify({
298
+ stage: "ws.sidebar_snapshot_built",
299
+ elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
300
+ projectGroupCount: data.projectGroups.length,
301
+ chatCount: totalChats,
302
+ totalChatCount: store.state.chatsById.size,
303
+ totalProjectCount: store.state.projectsById.size,
304
+ }))
305
+ }
306
+
307
+ const sidebar = {
308
+ data,
309
+ signature: JSON.stringify({
310
+ type: "sidebar" as const,
311
+ data,
312
+ }),
313
+ }
314
+
315
+ if (cache) {
316
+ cache.sidebar = sidebar
317
+ }
318
+
319
+ return sidebar
320
+ }
321
+
322
+ function createEnvelope(id: string, topic: SubscriptionTopic, cache?: SnapshotComputationCache): ServerEnvelope {
245
323
  if (topic.type === "sidebar") {
246
- const startedAt = performance.now()
247
- const data = deriveSidebarData(store.state, agent.getActiveStatuses())
248
- if (isSendToStartingProfilingEnabled()) {
249
- const totalChats = data.projectGroups.reduce((count, group) => count + group.chats.length, 0)
250
- console.log("[kanna/send->starting][server]", JSON.stringify({
251
- stage: "ws.sidebar_snapshot_built",
252
- elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
253
- projectGroupCount: data.projectGroups.length,
254
- chatCount: totalChats,
255
- totalChatCount: store.state.chatsById.size,
256
- totalProjectCount: store.state.projectsById.size,
257
- }))
258
- }
324
+ const sidebar = getSidebarSnapshotCacheEntry(cache)
259
325
  return {
260
326
  v: PROTOCOL_VERSION,
261
327
  type: "snapshot",
262
328
  id,
263
329
  snapshot: {
264
330
  type: "sidebar",
265
- data,
331
+ data: sidebar.data,
266
332
  },
267
333
  }
268
334
  }
@@ -360,7 +426,7 @@ export function createWsRouter({
360
426
 
361
427
  async function pushSnapshots(
362
428
  ws: ServerWebSocket<ClientState>,
363
- options?: { skipPrune?: boolean; filter?: SnapshotBroadcastFilter }
429
+ options?: { skipPrune?: boolean; filter?: SnapshotBroadcastFilter; cache?: SnapshotComputationCache }
364
430
  ) {
365
431
  const pushStartedAt = performance.now()
366
432
  if (!options?.skipPrune) {
@@ -374,11 +440,13 @@ export function createWsRouter({
374
440
  continue
375
441
  }
376
442
  const envelopeStartedAt = performance.now()
377
- const envelope = createEnvelope(id, topic)
443
+ const envelope = createEnvelope(id, topic, options?.cache)
378
444
  const createdAt = performance.now()
379
445
  if (envelope.type !== "snapshot") continue
380
- const signature = JSON.stringify(envelope.snapshot)
381
- const signatureReadyAt = performance.now()
446
+ const signature = topic.type === "sidebar"
447
+ ? getSidebarSnapshotCacheEntry(options?.cache).signature
448
+ : JSON.stringify(envelope.snapshot)
449
+ const signatureReadyAt = topic.type === "sidebar" ? createdAt : performance.now()
382
450
  if (snapshotSignatures.get(id) === signature) {
383
451
  skippedCount += 1
384
452
  continue
@@ -420,9 +488,10 @@ export function createWsRouter({
420
488
  async function broadcastSnapshots() {
421
489
  const startedAt = performance.now()
422
490
  let socketCount = 0
491
+ const cache: SnapshotComputationCache = {}
423
492
  for (const ws of sockets) {
424
493
  socketCount += 1
425
- await pushSnapshots(ws, { skipPrune: true })
494
+ await pushSnapshots(ws, { skipPrune: true, cache })
426
495
  }
427
496
  if (isSendToStartingProfilingEnabled()) {
428
497
  console.log("[kanna/send->starting][server]", JSON.stringify({
@@ -439,9 +508,10 @@ export function createWsRouter({
439
508
  async function broadcastFilteredSnapshots(filter: SnapshotBroadcastFilter) {
440
509
  const startedAt = performance.now()
441
510
  let socketCount = 0
511
+ const cache: SnapshotComputationCache = {}
442
512
  for (const ws of sockets) {
443
513
  socketCount += 1
444
- await pushSnapshots(ws, { skipPrune: true, filter })
514
+ await pushSnapshots(ws, { skipPrune: true, filter, cache })
445
515
  }
446
516
  if (isSendToStartingProfilingEnabled()) {
447
517
  console.log("[kanna/send->starting][server]", JSON.stringify({
@@ -646,6 +716,20 @@ export function createWsRouter({
646
716
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
647
717
  return
648
718
  }
719
+ case "settings.readLlmProvider": {
720
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: await resolvedLlmProvider.read() })
721
+ return
722
+ }
723
+ case "settings.writeLlmProvider": {
724
+ const snapshot = await resolvedLlmProvider.write({
725
+ provider: command.provider,
726
+ apiKey: command.apiKey,
727
+ model: command.model,
728
+ baseUrl: command.baseUrl,
729
+ })
730
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
731
+ return
732
+ }
649
733
  case "project.open": {
650
734
  await ensureProjectDirectory(command.localPath)
651
735
  const project = await store.openProject(command.localPath)
@@ -673,6 +757,12 @@ export function createWsRouter({
673
757
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
674
758
  break
675
759
  }
760
+ case "sidebar.reorderProjectGroups": {
761
+ await store.setSidebarProjectOrder(command.projectIds)
762
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
763
+ await broadcastFilteredSnapshots({ includeSidebar: true })
764
+ return
765
+ }
676
766
  case "project.readDiffPatch": {
677
767
  const project = store.getProject(command.projectId)
678
768
  if (!project) {
@@ -55,6 +55,14 @@ export function getKeybindingsFilePathDisplay(env: RuntimeEnv = getRuntimeEnv())
55
55
  return `${getDataRootDirDisplay(env)}/keybindings.json`
56
56
  }
57
57
 
58
+ export function getLlmProviderFilePath(homeDir: string, env: RuntimeEnv = getRuntimeEnv()) {
59
+ return `${getDataRootDir(homeDir, env)}/llm-provider.json`
60
+ }
61
+
62
+ export function getLlmProviderFilePathDisplay(env: RuntimeEnv = getRuntimeEnv()) {
63
+ return `${getDataRootDirDisplay(env)}/llm-provider.json`
64
+ }
65
+
58
66
  export function getCliInvocation(arg?: string) {
59
67
  return arg ? `${CLI_COMMAND} ${arg}` : CLI_COMMAND
60
68
  }
@@ -6,6 +6,7 @@ import type {
6
6
  ChatSnapshot,
7
7
  DiffCommitMode,
8
8
  KeybindingsSnapshot,
9
+ LlmProviderSnapshot,
9
10
  LocalProjectsSnapshot,
10
11
  ModelOptions,
11
12
  SidebarData,
@@ -50,12 +51,21 @@ export type ClientCommand =
50
51
  | { type: "project.open"; localPath: string }
51
52
  | { type: "project.create"; localPath: string; title: string }
52
53
  | { type: "project.remove"; projectId: string }
54
+ | { type: "sidebar.reorderProjectGroups"; projectIds: string[] }
53
55
  | { type: "project.readDiffPatch"; projectId: string; path: string }
54
56
  | { type: "system.ping" }
55
57
  | { type: "update.check"; force?: boolean }
56
58
  | { type: "update.install" }
57
59
  | { type: "settings.readKeybindings" }
58
60
  | { type: "settings.writeKeybindings"; bindings: KeybindingsSnapshot["bindings"] }
61
+ | { type: "settings.readLlmProvider" }
62
+ | {
63
+ type: "settings.writeLlmProvider"
64
+ provider: LlmProviderSnapshot["provider"]
65
+ apiKey: string
66
+ model: string
67
+ baseUrl: string
68
+ }
59
69
  | {
60
70
  type: "system.openExternal"
61
71
  localPath: string
@@ -189,6 +199,7 @@ export type ServerSnapshot =
189
199
  | { type: "local-projects"; data: LocalProjectsSnapshot }
190
200
  | { type: "update"; data: UpdateSnapshot }
191
201
  | { type: "keybindings"; data: KeybindingsSnapshot }
202
+ | { type: "llm-provider"; data: LlmProviderSnapshot }
192
203
  | { type: "chat"; data: ChatSnapshot | null }
193
204
  | { type: "project-git"; data: ChatDiffSnapshot | null }
194
205
  | { type: "terminal"; data: TerminalSnapshot | null }
@@ -2,6 +2,9 @@ export const STORE_VERSION = 2 as const
2
2
  export const PROTOCOL_VERSION = 1 as const
3
3
 
4
4
  export type AgentProvider = "claude" | "codex"
5
+ export type LlmProviderKind = "openai" | "openrouter" | "custom"
6
+ export const DEFAULT_OPENAI_SDK_MODEL = "gpt-5.4-mini"
7
+ export const DEFAULT_OPENROUTER_SDK_MODEL = "moonshotai/kimi-k2.5:nitro"
5
8
 
6
9
  export type AttachmentKind = "image" | "file"
7
10
 
@@ -224,6 +227,9 @@ export interface SidebarProjectGroup {
224
227
  groupKey: string
225
228
  localPath: string
226
229
  chats: SidebarChatRow[]
230
+ previewChats: SidebarChatRow[]
231
+ olderChats: SidebarChatRow[]
232
+ defaultCollapsed: boolean
227
233
  }
228
234
 
229
235
  export interface SidebarData {
@@ -246,6 +252,24 @@ export interface LocalProjectsSnapshot {
246
252
  projects: LocalProjectSummary[]
247
253
  }
248
254
 
255
+ export interface LlmProviderFile {
256
+ provider?: LlmProviderKind
257
+ apiKey?: string
258
+ model?: string
259
+ baseUrl?: string | null
260
+ }
261
+
262
+ export interface LlmProviderSnapshot {
263
+ provider: LlmProviderKind
264
+ apiKey: string
265
+ model: string
266
+ baseUrl: string
267
+ resolvedBaseUrl: string
268
+ enabled: boolean
269
+ warning: string | null
270
+ filePathDisplay: string
271
+ }
272
+
249
273
  export type UpdateStatus =
250
274
  | "idle"
251
275
  | "checking"