kanna-code 0.31.0 → 0.32.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,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,98 @@ 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
+ validate: async () => ({
146
+ ok: true,
147
+ error: null,
148
+ }),
149
+ },
150
+ refreshDiscovery: async () => [],
151
+ getDiscoveredProjects: () => [],
152
+ machineDisplayName: "Local Machine",
153
+ updateManager: null,
154
+ })
155
+ const ws = new FakeWebSocket()
156
+
157
+ await router.handleMessage(
158
+ ws as never,
159
+ JSON.stringify({
160
+ v: 1,
161
+ type: "command",
162
+ id: "llm-read-1",
163
+ command: { type: "settings.readLlmProvider" },
164
+ })
165
+ )
166
+
167
+ await router.handleMessage(
168
+ ws as never,
169
+ JSON.stringify({
170
+ v: 1,
171
+ type: "command",
172
+ id: "llm-write-1",
173
+ command: {
174
+ type: "settings.writeLlmProvider",
175
+ provider: "custom",
176
+ apiKey: "test-key",
177
+ model: "gpt-test",
178
+ baseUrl: "https://example.com/v1",
179
+ },
180
+ })
181
+ )
182
+
183
+ expect(ws.sent).toEqual([
184
+ {
185
+ v: PROTOCOL_VERSION,
186
+ type: "ack",
187
+ id: "llm-read-1",
188
+ result: DEFAULT_LLM_PROVIDER_SNAPSHOT,
189
+ },
190
+ {
191
+ v: PROTOCOL_VERSION,
192
+ type: "ack",
193
+ id: "llm-write-1",
194
+ result: {
195
+ ...DEFAULT_LLM_PROVIDER_SNAPSHOT,
196
+ provider: "custom",
197
+ apiKey: "test-key",
198
+ model: "gpt-test",
199
+ baseUrl: "https://example.com/v1",
200
+ resolvedBaseUrl: "https://example.com/v1",
201
+ enabled: true,
202
+ },
203
+ },
204
+ ])
205
+ expect(writes).toEqual([{
206
+ provider: "custom",
207
+ apiKey: "test-key",
208
+ model: "gpt-test",
209
+ baseUrl: "https://example.com/v1",
210
+ }])
211
+ })
212
+
86
213
  test("acks terminal.input without rebroadcasting terminal snapshots", async () => {
87
214
  const router = createWsRouter({
88
215
  store: { state: createEmptyState() } as never,
@@ -183,6 +310,54 @@ describe("ws-router", () => {
183
310
  })
184
311
  })
185
312
 
313
+ test("reuses one sidebar derivation across sockets in the same broadcast pass", async () => {
314
+ const state = createEmptyState()
315
+ state.projectsById.set("project-1", {
316
+ id: "project-1",
317
+ localPath: "/tmp/project",
318
+ title: "Project",
319
+ createdAt: 1,
320
+ updatedAt: 1,
321
+ })
322
+
323
+ let activeStatusCalls = 0
324
+ const router = createWsRouter({
325
+ store: { state } as never,
326
+ agent: {
327
+ getActiveStatuses: () => {
328
+ activeStatusCalls += 1
329
+ return new Map()
330
+ },
331
+ getDrainingChatIds: () => new Set(),
332
+ } as never,
333
+ terminals: {
334
+ getSnapshot: () => null,
335
+ onEvent: () => () => {},
336
+ } as never,
337
+ keybindings: {
338
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
339
+ onChange: () => () => {},
340
+ } as never,
341
+ refreshDiscovery: async () => [],
342
+ getDiscoveredProjects: () => [],
343
+ machineDisplayName: "Local Machine",
344
+ updateManager: null,
345
+ })
346
+
347
+ const wsA = new FakeWebSocket()
348
+ const wsB = new FakeWebSocket()
349
+ router.handleOpen(wsA as never)
350
+ router.handleOpen(wsB as never)
351
+ wsA.data.subscriptions.set("sidebar-a", { type: "sidebar" })
352
+ wsB.data.subscriptions.set("sidebar-b", { type: "sidebar" })
353
+
354
+ await router.broadcastSnapshots()
355
+
356
+ expect(activeStatusCalls).toBe(1)
357
+ expect(wsA.sent).toHaveLength(1)
358
+ expect(wsB.sent).toHaveLength(1)
359
+ })
360
+
186
361
  test("subscribes to project git snapshots independently from chat snapshots", async () => {
187
362
  const state = createEmptyState()
188
363
  state.projectsById.set("project-1", {
@@ -625,7 +800,7 @@ describe("ws-router", () => {
625
800
  snapshot: {
626
801
  type: "sidebar",
627
802
  data: {
628
- projectGroups: [{
803
+ projectGroups: [withSidebarGroupDefaults({
629
804
  groupKey: "project-1",
630
805
  localPath: "/tmp/project",
631
806
  chats: [{
@@ -637,10 +812,9 @@ describe("ws-router", () => {
637
812
  unread: false,
638
813
  localPath: "/tmp/project",
639
814
  provider: null,
640
- lastMessageAt: undefined,
641
815
  hasAutomation: false,
642
816
  }],
643
- }],
817
+ })],
644
818
  },
645
819
  },
646
820
  })
@@ -651,7 +825,7 @@ describe("ws-router", () => {
651
825
  snapshot: {
652
826
  type: "sidebar",
653
827
  data: {
654
- projectGroups: [{
828
+ projectGroups: [withSidebarGroupDefaults({
655
829
  groupKey: "project-1",
656
830
  localPath: "/tmp/project",
657
831
  chats: [{
@@ -663,10 +837,102 @@ describe("ws-router", () => {
663
837
  unread: false,
664
838
  localPath: "/tmp/project",
665
839
  provider: null,
666
- lastMessageAt: undefined,
667
840
  hasAutomation: false,
668
841
  }],
669
- }],
842
+ })],
843
+ },
844
+ },
845
+ })
846
+ })
847
+
848
+ test("reorders sidebar project groups on the server and rebroadcasts the snapshot", async () => {
849
+ const state = createEmptyState()
850
+ state.projectsById.set("project-1", {
851
+ id: "project-1",
852
+ localPath: "/tmp/project-1",
853
+ title: "Project 1",
854
+ createdAt: 1,
855
+ updatedAt: 1,
856
+ })
857
+ state.projectsById.set("project-2", {
858
+ id: "project-2",
859
+ localPath: "/tmp/project-2",
860
+ title: "Project 2",
861
+ createdAt: 2,
862
+ updatedAt: 2,
863
+ })
864
+
865
+ const setSidebarProjectOrderCalls: string[][] = []
866
+ const router = createWsRouter({
867
+ store: {
868
+ state,
869
+ async setSidebarProjectOrder(projectIds: string[]) {
870
+ setSidebarProjectOrderCalls.push(projectIds)
871
+ state.sidebarProjectOrder = [...projectIds]
872
+ },
873
+ } as never,
874
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
875
+ terminals: {
876
+ getSnapshot: () => null,
877
+ onEvent: () => () => {},
878
+ } as never,
879
+ keybindings: {
880
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
881
+ onChange: () => () => {},
882
+ } as never,
883
+ refreshDiscovery: async () => [],
884
+ getDiscoveredProjects: () => [],
885
+ machineDisplayName: "Local Machine",
886
+ updateManager: null,
887
+ })
888
+ const ws = new FakeWebSocket()
889
+ router.handleOpen(ws as never)
890
+
891
+ await router.handleMessage(
892
+ ws as never,
893
+ JSON.stringify({
894
+ v: 1,
895
+ type: "subscribe",
896
+ id: "sidebar-sub-1",
897
+ topic: { type: "sidebar" },
898
+ })
899
+ )
900
+
901
+ await router.handleMessage(
902
+ ws as never,
903
+ JSON.stringify({
904
+ v: 1,
905
+ type: "command",
906
+ id: "sidebar-reorder-1",
907
+ command: { type: "sidebar.reorderProjectGroups", projectIds: ["project-1", "project-2"] },
908
+ })
909
+ )
910
+
911
+ expect(setSidebarProjectOrderCalls).toEqual([["project-1", "project-2"]])
912
+ expect(ws.sent.at(-2)).toEqual({
913
+ v: PROTOCOL_VERSION,
914
+ type: "ack",
915
+ id: "sidebar-reorder-1",
916
+ })
917
+ expect(ws.sent.at(-1)).toEqual({
918
+ v: PROTOCOL_VERSION,
919
+ type: "snapshot",
920
+ id: "sidebar-sub-1",
921
+ snapshot: {
922
+ type: "sidebar",
923
+ data: {
924
+ projectGroups: [
925
+ withSidebarGroupDefaults({
926
+ groupKey: "project-1",
927
+ localPath: "/tmp/project-1",
928
+ chats: [],
929
+ }),
930
+ withSidebarGroupDefaults({
931
+ groupKey: "project-2",
932
+ localPath: "/tmp/project-2",
933
+ chats: [],
934
+ }),
935
+ ],
670
936
  },
671
937
  },
672
938
  })
@@ -741,9 +1007,11 @@ describe("ws-router", () => {
741
1007
  type: "sidebar",
742
1008
  data: {
743
1009
  projectGroups: [{
744
- groupKey: "project-1",
745
- localPath: "/tmp/project",
746
- chats: [],
1010
+ ...withSidebarGroupDefaults({
1011
+ groupKey: "project-1",
1012
+ localPath: "/tmp/project",
1013
+ chats: [],
1014
+ }),
747
1015
  }],
748
1016
  },
749
1017
  },
@@ -12,6 +12,8 @@ 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"
16
+ import type { LlmProviderValidationResult } from "../shared/types"
15
17
 
16
18
  const DEFAULT_CHAT_RECENT_LIMIT = 200
17
19
 
@@ -96,6 +98,11 @@ interface CreateWsRouterArgs {
96
98
  agent: AgentCoordinator
97
99
  terminals: TerminalManager
98
100
  keybindings: KeybindingsManager
101
+ llmProvider?: {
102
+ read: () => Promise<LlmProviderSnapshot>
103
+ write: (value: Pick<LlmProviderSnapshot, "provider" | "apiKey" | "model" | "baseUrl">) => Promise<LlmProviderSnapshot>
104
+ validate: (value: Pick<LlmProviderSnapshot, "provider" | "apiKey" | "model" | "baseUrl">) => Promise<LlmProviderValidationResult>
105
+ }
99
106
  refreshDiscovery: () => Promise<DiscoveredProject[]>
100
107
  getDiscoveredProjects: () => DiscoveredProject[]
101
108
  machineDisplayName: string
@@ -112,6 +119,13 @@ interface SnapshotBroadcastFilter {
112
119
  terminalIds?: Set<string>
113
120
  }
114
121
 
122
+ interface SnapshotComputationCache {
123
+ sidebar?: {
124
+ data: ReturnType<typeof deriveSidebarData>
125
+ signature: string
126
+ }
127
+ }
128
+
115
129
  function send(ws: ServerWebSocket<ClientState>, message: ServerEnvelope) {
116
130
  const payload = JSON.stringify(message)
117
131
  ws.send(payload)
@@ -132,6 +146,7 @@ export function createWsRouter({
132
146
  agent,
133
147
  terminals,
134
148
  keybindings,
149
+ llmProvider,
135
150
  refreshDiscovery,
136
151
  getDiscoveredProjects,
137
152
  machineDisplayName,
@@ -160,6 +175,44 @@ export function createWsRouter({
160
175
  ignoreFile: async () => ({ snapshotChanged: false }),
161
176
  readPatch: async () => ({ patch: "" }),
162
177
  }
178
+ const resolvedLlmProvider = llmProvider ?? {
179
+ read: async () => ({
180
+ provider: "openai" as const,
181
+ apiKey: "",
182
+ model: "gpt-5.4-mini",
183
+ baseUrl: "",
184
+ resolvedBaseUrl: "https://api.openai.com/v1",
185
+ enabled: false,
186
+ warning: null,
187
+ filePathDisplay: "~/.kanna/llm-provider.json",
188
+ }),
189
+ write: async ({ provider, apiKey, model, baseUrl }: {
190
+ provider: "openai" | "openrouter" | "custom"
191
+ apiKey: string
192
+ model: string
193
+ baseUrl: string
194
+ }) => ({
195
+ provider,
196
+ apiKey,
197
+ model,
198
+ baseUrl,
199
+ resolvedBaseUrl: provider === "openrouter"
200
+ ? "https://openrouter.ai/api/v1"
201
+ : provider === "custom"
202
+ ? baseUrl
203
+ : "https://api.openai.com/v1",
204
+ enabled: false,
205
+ warning: null,
206
+ filePathDisplay: "~/.kanna/llm-provider.json",
207
+ }),
208
+ validate: async () => ({
209
+ ok: false,
210
+ error: {
211
+ type: "config_error",
212
+ message: "LLM provider validation unavailable.",
213
+ },
214
+ }),
215
+ }
163
216
 
164
217
  function getProtectedChatIds() {
165
218
  const activeStatuses = agent.getActiveStatuses()
@@ -241,28 +294,50 @@ export function createWsRouter({
241
294
  return true
242
295
  }
243
296
 
244
- function createEnvelope(id: string, topic: SubscriptionTopic): ServerEnvelope {
297
+ function getSidebarSnapshotCacheEntry(cache?: SnapshotComputationCache) {
298
+ if (cache?.sidebar) {
299
+ return cache.sidebar
300
+ }
301
+
302
+ const startedAt = performance.now()
303
+ const data = deriveSidebarData(store.state, agent.getActiveStatuses())
304
+ if (isSendToStartingProfilingEnabled()) {
305
+ const totalChats = data.projectGroups.reduce((count, group) => count + group.chats.length, 0)
306
+ console.log("[kanna/send->starting][server]", JSON.stringify({
307
+ stage: "ws.sidebar_snapshot_built",
308
+ elapsedMs: Number((performance.now() - startedAt).toFixed(1)),
309
+ projectGroupCount: data.projectGroups.length,
310
+ chatCount: totalChats,
311
+ totalChatCount: store.state.chatsById.size,
312
+ totalProjectCount: store.state.projectsById.size,
313
+ }))
314
+ }
315
+
316
+ const sidebar = {
317
+ data,
318
+ signature: JSON.stringify({
319
+ type: "sidebar" as const,
320
+ data,
321
+ }),
322
+ }
323
+
324
+ if (cache) {
325
+ cache.sidebar = sidebar
326
+ }
327
+
328
+ return sidebar
329
+ }
330
+
331
+ function createEnvelope(id: string, topic: SubscriptionTopic, cache?: SnapshotComputationCache): ServerEnvelope {
245
332
  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
- }
333
+ const sidebar = getSidebarSnapshotCacheEntry(cache)
259
334
  return {
260
335
  v: PROTOCOL_VERSION,
261
336
  type: "snapshot",
262
337
  id,
263
338
  snapshot: {
264
339
  type: "sidebar",
265
- data,
340
+ data: sidebar.data,
266
341
  },
267
342
  }
268
343
  }
@@ -360,7 +435,7 @@ export function createWsRouter({
360
435
 
361
436
  async function pushSnapshots(
362
437
  ws: ServerWebSocket<ClientState>,
363
- options?: { skipPrune?: boolean; filter?: SnapshotBroadcastFilter }
438
+ options?: { skipPrune?: boolean; filter?: SnapshotBroadcastFilter; cache?: SnapshotComputationCache }
364
439
  ) {
365
440
  const pushStartedAt = performance.now()
366
441
  if (!options?.skipPrune) {
@@ -374,11 +449,13 @@ export function createWsRouter({
374
449
  continue
375
450
  }
376
451
  const envelopeStartedAt = performance.now()
377
- const envelope = createEnvelope(id, topic)
452
+ const envelope = createEnvelope(id, topic, options?.cache)
378
453
  const createdAt = performance.now()
379
454
  if (envelope.type !== "snapshot") continue
380
- const signature = JSON.stringify(envelope.snapshot)
381
- const signatureReadyAt = performance.now()
455
+ const signature = topic.type === "sidebar"
456
+ ? getSidebarSnapshotCacheEntry(options?.cache).signature
457
+ : JSON.stringify(envelope.snapshot)
458
+ const signatureReadyAt = topic.type === "sidebar" ? createdAt : performance.now()
382
459
  if (snapshotSignatures.get(id) === signature) {
383
460
  skippedCount += 1
384
461
  continue
@@ -420,9 +497,10 @@ export function createWsRouter({
420
497
  async function broadcastSnapshots() {
421
498
  const startedAt = performance.now()
422
499
  let socketCount = 0
500
+ const cache: SnapshotComputationCache = {}
423
501
  for (const ws of sockets) {
424
502
  socketCount += 1
425
- await pushSnapshots(ws, { skipPrune: true })
503
+ await pushSnapshots(ws, { skipPrune: true, cache })
426
504
  }
427
505
  if (isSendToStartingProfilingEnabled()) {
428
506
  console.log("[kanna/send->starting][server]", JSON.stringify({
@@ -439,9 +517,10 @@ export function createWsRouter({
439
517
  async function broadcastFilteredSnapshots(filter: SnapshotBroadcastFilter) {
440
518
  const startedAt = performance.now()
441
519
  let socketCount = 0
520
+ const cache: SnapshotComputationCache = {}
442
521
  for (const ws of sockets) {
443
522
  socketCount += 1
444
- await pushSnapshots(ws, { skipPrune: true, filter })
523
+ await pushSnapshots(ws, { skipPrune: true, filter, cache })
445
524
  }
446
525
  if (isSendToStartingProfilingEnabled()) {
447
526
  console.log("[kanna/send->starting][server]", JSON.stringify({
@@ -646,6 +725,30 @@ export function createWsRouter({
646
725
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
647
726
  return
648
727
  }
728
+ case "settings.readLlmProvider": {
729
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: await resolvedLlmProvider.read() })
730
+ return
731
+ }
732
+ case "settings.writeLlmProvider": {
733
+ const snapshot = await resolvedLlmProvider.write({
734
+ provider: command.provider,
735
+ apiKey: command.apiKey,
736
+ model: command.model,
737
+ baseUrl: command.baseUrl,
738
+ })
739
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
740
+ return
741
+ }
742
+ case "settings.validateLlmProvider": {
743
+ const result = await resolvedLlmProvider.validate({
744
+ provider: command.provider,
745
+ apiKey: command.apiKey,
746
+ model: command.model,
747
+ baseUrl: command.baseUrl,
748
+ })
749
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
750
+ return
751
+ }
649
752
  case "project.open": {
650
753
  await ensureProjectDirectory(command.localPath)
651
754
  const project = await store.openProject(command.localPath)
@@ -673,6 +776,12 @@ export function createWsRouter({
673
776
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
674
777
  break
675
778
  }
779
+ case "sidebar.reorderProjectGroups": {
780
+ await store.setSidebarProjectOrder(command.projectIds)
781
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
782
+ await broadcastFilteredSnapshots({ includeSidebar: true })
783
+ return
784
+ }
676
785
  case "project.readDiffPatch": {
677
786
  const project = store.getProject(command.projectId)
678
787
  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,28 @@ 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
+ }
69
+ | {
70
+ type: "settings.validateLlmProvider"
71
+ provider: LlmProviderSnapshot["provider"]
72
+ apiKey: string
73
+ model: string
74
+ baseUrl: string
75
+ }
59
76
  | {
60
77
  type: "system.openExternal"
61
78
  localPath: string
@@ -189,6 +206,7 @@ export type ServerSnapshot =
189
206
  | { type: "local-projects"; data: LocalProjectsSnapshot }
190
207
  | { type: "update"; data: UpdateSnapshot }
191
208
  | { type: "keybindings"; data: KeybindingsSnapshot }
209
+ | { type: "llm-provider"; data: LlmProviderSnapshot }
192
210
  | { type: "chat"; data: ChatSnapshot | null }
193
211
  | { type: "project-git"; data: ChatDiffSnapshot | null }
194
212
  | { 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,29 @@ 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
+
273
+ export interface LlmProviderValidationResult {
274
+ ok: boolean
275
+ error: unknown | null
276
+ }
277
+
249
278
  export type UpdateStatus =
250
279
  | "idle"
251
280
  | "checking"