kanna-code 0.30.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.
@@ -59,6 +59,7 @@ function getReplayEventPriority(event: StoreEvent) {
59
59
  switch (event.type) {
60
60
  case "project_opened":
61
61
  case "project_removed":
62
+ case "sidebar_project_order_set":
62
63
  return 0
63
64
  case "chat_created":
64
65
  return 1
@@ -198,6 +199,7 @@ export class EventStore {
198
199
  unread: chat.unread ?? false,
199
200
  })
200
201
  }
202
+ this.state.sidebarProjectOrder = [...(parsed.sidebarProjectOrder ?? [])]
201
203
  if (parsed.queuedMessages?.length) {
202
204
  for (const queuedSet of parsed.queuedMessages) {
203
205
  this.state.queuedMessagesByChatId.set(queuedSet.chatId, queuedSet.entries.map((entry) => ({
@@ -223,6 +225,7 @@ export class EventStore {
223
225
  this.state.projectIdsByPath.clear()
224
226
  this.state.chatsById.clear()
225
227
  this.state.queuedMessagesByChatId.clear()
228
+ this.state.sidebarProjectOrder = []
226
229
  this.cachedTranscript = null
227
230
  }
228
231
 
@@ -322,6 +325,10 @@ export class EventStore {
322
325
  this.state.projectIdsByPath.delete(project.localPath)
323
326
  break
324
327
  }
328
+ case "sidebar_project_order_set": {
329
+ this.state.sidebarProjectOrder = [...event.projectIds]
330
+ break
331
+ }
325
332
  case "chat_created": {
326
333
  const chat = {
327
334
  id: event.chatId,
@@ -527,6 +534,30 @@ export class EventStore {
527
534
  await this.append(this.projectsLogPath, event)
528
535
  }
529
536
 
537
+ async setSidebarProjectOrder(projectIds: string[]) {
538
+ const validProjectIds = projectIds.filter((projectId) => {
539
+ const project = this.state.projectsById.get(projectId)
540
+ return Boolean(project && !project.deletedAt)
541
+ })
542
+
543
+ const uniqueProjectIds = [...new Set(validProjectIds)]
544
+ const current = this.state.sidebarProjectOrder
545
+ if (
546
+ uniqueProjectIds.length === current.length
547
+ && uniqueProjectIds.every((projectId, index) => current[index] === projectId)
548
+ ) {
549
+ return
550
+ }
551
+
552
+ const event: ProjectEvent = {
553
+ v: STORE_VERSION,
554
+ type: "sidebar_project_order_set",
555
+ timestamp: Date.now(),
556
+ projectIds: uniqueProjectIds,
557
+ }
558
+ await this.append(this.projectsLogPath, event)
559
+ }
560
+
530
561
  async createChat(projectId: string) {
531
562
  const project = this.state.projectsById.get(projectId)
532
563
  if (!project || project.deletedAt) {
@@ -930,6 +961,7 @@ export class EventStore {
930
961
  v: STORE_VERSION,
931
962
  generatedAt: Date.now(),
932
963
  projects: this.listProjects().map((project) => ({ ...project })),
964
+ sidebarProjectOrder: [...this.state.sidebarProjectOrder],
933
965
  chats: [...this.state.chatsById.values()]
934
966
  .filter((chat) => !chat.deletedAt)
935
967
  .map((chat) => ({ ...chat })),
@@ -25,6 +25,7 @@ export interface StoreState {
25
25
  projectIdsByPath: Map<string, string>
26
26
  chatsById: Map<string, ChatRecord>
27
27
  queuedMessagesByChatId: Map<string, QueuedChatMessage[]>
28
+ sidebarProjectOrder: string[]
28
29
  }
29
30
 
30
31
  export interface SnapshotFile {
@@ -32,6 +33,7 @@ export interface SnapshotFile {
32
33
  generatedAt: number
33
34
  projects: ProjectRecord[]
34
35
  chats: ChatRecord[]
36
+ sidebarProjectOrder?: string[]
35
37
  queuedMessages?: Array<{ chatId: string; entries: QueuedChatMessage[] }>
36
38
  messages?: Array<{ chatId: string; entries: TranscriptEntry[] }>
37
39
  }
@@ -48,6 +50,11 @@ export type ProjectEvent = {
48
50
  type: "project_removed"
49
51
  timestamp: number
50
52
  projectId: string
53
+ } | {
54
+ v: 2
55
+ type: "sidebar_project_order_set"
56
+ timestamp: number
57
+ projectIds: string[]
51
58
  }
52
59
 
53
60
  export type ChatEvent =
@@ -160,6 +167,7 @@ export function createEmptyState(): StoreState {
160
167
  projectIdsByPath: new Map(),
161
168
  chatsById: new Map(),
162
169
  queuedMessagesByChatId: new Map(),
170
+ sidebarProjectOrder: [],
163
171
  }
164
172
  }
165
173
 
@@ -15,6 +15,16 @@ describe("generateCommitMessageDetailed", () => {
15
15
  }],
16
16
  },
17
17
  new QuickResponseAdapter({
18
+ readLlmProvider: async () => ({
19
+ provider: "openai",
20
+ apiKey: "",
21
+ model: "",
22
+ baseUrl: "",
23
+ resolvedBaseUrl: "https://api.openai.com/v1",
24
+ enabled: false,
25
+ warning: null,
26
+ filePathDisplay: "~/.kanna/llm-provider.json",
27
+ }),
18
28
  runClaudeStructured: async () => ({
19
29
  subject: " Add login UI.\nextra line",
20
30
  body: " - wire form\n- add validation ",
@@ -42,6 +52,16 @@ describe("generateCommitMessageDetailed", () => {
42
52
  }],
43
53
  },
44
54
  new QuickResponseAdapter({
55
+ readLlmProvider: async () => ({
56
+ provider: "openai",
57
+ apiKey: "",
58
+ model: "",
59
+ baseUrl: "",
60
+ resolvedBaseUrl: "https://api.openai.com/v1",
61
+ enabled: false,
62
+ warning: null,
63
+ filePathDisplay: "~/.kanna/llm-provider.json",
64
+ }),
45
65
  runClaudeStructured: async () => {
46
66
  throw new Error("Not authenticated")
47
67
  },
@@ -24,7 +24,7 @@ export interface GenerateCommitMessageResult {
24
24
  failureMessage: string | null
25
25
  }
26
26
 
27
- function summarizeFailures(failures: Array<{ provider: "claude" | "codex"; reason: string }>) {
27
+ function summarizeFailures(failures: Array<{ provider: "openai" | "claude" | "codex"; reason: string }>) {
28
28
  if (failures.length === 0) return null
29
29
  return failures.map((failure) => failure.reason).join("; ")
30
30
  }
@@ -29,7 +29,7 @@ export interface GenerateChatTitleResult {
29
29
  failureMessage: string | null
30
30
  }
31
31
 
32
- function summarizeFailures(failures: Array<{ provider: "claude" | "codex"; reason: string }>) {
32
+ function summarizeFailures(failures: Array<{ provider: "openai" | "claude" | "codex"; reason: string }>) {
33
33
  if (failures.length === 0) return null
34
34
  return failures.map((failure) => failure.reason).join("; ")
35
35
  }
@@ -0,0 +1,134 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import path from "node:path"
5
+ import { DEFAULT_OPENAI_SDK_MODEL, DEFAULT_OPENROUTER_SDK_MODEL } from "../shared/types"
6
+ import {
7
+ normalizeLlmProviderSnapshot,
8
+ OPENAI_BASE_URL,
9
+ OPENROUTER_BASE_URL,
10
+ readLlmProviderSnapshot,
11
+ resolveLlmProviderBaseUrl,
12
+ writeLlmProviderSnapshot,
13
+ } from "./llm-provider"
14
+
15
+ let tempDirs: string[] = []
16
+ const TEST_FILE_PATH = "/tmp/kanna-test-llm-provider.json"
17
+
18
+ afterEach(async () => {
19
+ await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
20
+ tempDirs = []
21
+ })
22
+
23
+ async function createTempFilePath() {
24
+ const dir = await mkdtemp(path.join(tmpdir(), "kanna-llm-provider-"))
25
+ tempDirs.push(dir)
26
+ return path.join(dir, "llm-provider.json")
27
+ }
28
+
29
+ describe("resolveLlmProviderBaseUrl", () => {
30
+ test("derives known provider URLs and preserves custom URLs", () => {
31
+ expect(resolveLlmProviderBaseUrl("openai", "")).toBe(OPENAI_BASE_URL)
32
+ expect(resolveLlmProviderBaseUrl("openrouter", "")).toBe(OPENROUTER_BASE_URL)
33
+ expect(resolveLlmProviderBaseUrl("custom", " https://example.com/v1 ")).toBe("https://example.com/v1")
34
+ })
35
+ })
36
+
37
+ describe("normalizeLlmProviderSnapshot", () => {
38
+ test("normalizes a valid custom provider config", () => {
39
+ expect(normalizeLlmProviderSnapshot({
40
+ provider: "custom",
41
+ apiKey: " test-key ",
42
+ model: " gpt-test ",
43
+ baseUrl: " https://example.com/v1 ",
44
+ }, TEST_FILE_PATH)).toEqual({
45
+ provider: "custom",
46
+ apiKey: "test-key",
47
+ model: "gpt-test",
48
+ baseUrl: "https://example.com/v1",
49
+ resolvedBaseUrl: "https://example.com/v1",
50
+ enabled: true,
51
+ warning: null,
52
+ filePathDisplay: TEST_FILE_PATH,
53
+ })
54
+ })
55
+
56
+ test("disables invalid configs with a warning", () => {
57
+ const snapshot = normalizeLlmProviderSnapshot({
58
+ provider: "custom",
59
+ apiKey: "test-key",
60
+ model: "gpt-test",
61
+ baseUrl: "",
62
+ }, TEST_FILE_PATH)
63
+
64
+ expect(snapshot.enabled).toBe(false)
65
+ expect(snapshot.warning).toContain("custom provider requires a baseUrl")
66
+ })
67
+ })
68
+
69
+ describe("readLlmProviderSnapshot", () => {
70
+ test("returns defaults when the file does not exist", async () => {
71
+ const filePath = await createTempFilePath()
72
+ expect(await readLlmProviderSnapshot(filePath)).toEqual({
73
+ provider: "openai",
74
+ apiKey: "",
75
+ model: DEFAULT_OPENAI_SDK_MODEL,
76
+ baseUrl: "",
77
+ resolvedBaseUrl: OPENAI_BASE_URL,
78
+ enabled: false,
79
+ warning: null,
80
+ filePathDisplay: filePath,
81
+ })
82
+ })
83
+
84
+ test("returns a warning when the file contains invalid json", async () => {
85
+ const filePath = await createTempFilePath()
86
+ await writeFile(filePath, "{not-json", "utf8")
87
+
88
+ const snapshot = await readLlmProviderSnapshot(filePath)
89
+ expect(snapshot.enabled).toBe(false)
90
+ expect(snapshot.warning).toContain("invalid JSON")
91
+ })
92
+
93
+ test("fills named-provider default models when the file omits them", async () => {
94
+ const filePath = await createTempFilePath()
95
+ await writeFile(filePath, JSON.stringify({
96
+ provider: "openrouter",
97
+ apiKey: "test-key",
98
+ baseUrl: null,
99
+ }), "utf8")
100
+
101
+ const snapshot = await readLlmProviderSnapshot(filePath)
102
+ expect(snapshot.model).toBe(DEFAULT_OPENROUTER_SDK_MODEL)
103
+ expect(snapshot.enabled).toBe(true)
104
+ })
105
+ })
106
+
107
+ describe("writeLlmProviderSnapshot", () => {
108
+ test("writes normalized config to disk", async () => {
109
+ const filePath = await createTempFilePath()
110
+ const snapshot = await writeLlmProviderSnapshot({
111
+ provider: "openrouter",
112
+ apiKey: " test-key ",
113
+ model: " openrouter/model ",
114
+ baseUrl: "ignored",
115
+ }, filePath)
116
+
117
+ expect(snapshot).toEqual({
118
+ provider: "openrouter",
119
+ apiKey: "test-key",
120
+ model: "openrouter/model",
121
+ baseUrl: "ignored",
122
+ resolvedBaseUrl: OPENROUTER_BASE_URL,
123
+ enabled: true,
124
+ warning: null,
125
+ filePathDisplay: filePath,
126
+ })
127
+ expect(await Bun.file(filePath).json()).toEqual({
128
+ provider: "openrouter",
129
+ apiKey: "test-key",
130
+ model: "openrouter/model",
131
+ baseUrl: null,
132
+ })
133
+ })
134
+ })
@@ -0,0 +1,149 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises"
2
+ import { homedir } from "node:os"
3
+ import path from "node:path"
4
+ import { getLlmProviderFilePath } from "../shared/branding"
5
+ import {
6
+ DEFAULT_OPENAI_SDK_MODEL,
7
+ DEFAULT_OPENROUTER_SDK_MODEL,
8
+ type LlmProviderFile,
9
+ type LlmProviderKind,
10
+ type LlmProviderSnapshot,
11
+ } from "../shared/types"
12
+
13
+ export const OPENAI_BASE_URL = "https://api.openai.com/v1"
14
+ export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
15
+
16
+ const DEFAULT_PROVIDER: LlmProviderKind = "openai"
17
+
18
+ function formatDisplayPath(filePath: string) {
19
+ const homePath = homedir()
20
+ if (filePath === homePath) return "~"
21
+ if (filePath.startsWith(`${homePath}${path.sep}`)) {
22
+ return `~${filePath.slice(homePath.length)}`
23
+ }
24
+ return filePath
25
+ }
26
+
27
+ function resolveProvider(value: unknown) {
28
+ if (value === "openai" || value === "openrouter" || value === "custom") {
29
+ return value
30
+ }
31
+ return null
32
+ }
33
+
34
+ function normalizeString(value: unknown) {
35
+ return typeof value === "string" ? value.trim() : ""
36
+ }
37
+
38
+ export function resolveLlmProviderBaseUrl(provider: LlmProviderKind, baseUrl: string) {
39
+ if (provider === "openai") return OPENAI_BASE_URL
40
+ if (provider === "openrouter") return OPENROUTER_BASE_URL
41
+ return baseUrl.trim()
42
+ }
43
+
44
+ export function resolveLlmProviderDefaultModel(provider: LlmProviderKind) {
45
+ if (provider === "openai") return DEFAULT_OPENAI_SDK_MODEL
46
+ if (provider === "openrouter") return DEFAULT_OPENROUTER_SDK_MODEL
47
+ return ""
48
+ }
49
+
50
+ export function normalizeLlmProviderSnapshot(
51
+ value: unknown,
52
+ filePath = getLlmProviderFilePath(homedir())
53
+ ): LlmProviderSnapshot {
54
+ const source = value && typeof value === "object" && !Array.isArray(value)
55
+ ? value as Record<string, unknown>
56
+ : null
57
+ const warnings: string[] = []
58
+
59
+ if (!source) {
60
+ return createDefaultSnapshot(
61
+ filePath,
62
+ value === undefined || value === null ? null : "LLM provider file must contain a JSON object. Using defaults."
63
+ )
64
+ }
65
+
66
+ const provider = resolveProvider(source.provider)
67
+ const apiKey = normalizeString(source.apiKey)
68
+ const model = normalizeString(source.model)
69
+ const baseUrl = normalizeString(source.baseUrl)
70
+
71
+ if (!provider) {
72
+ warnings.push("provider must be one of openai, openrouter, or custom")
73
+ }
74
+ if (source.apiKey !== undefined && typeof source.apiKey !== "string") {
75
+ warnings.push("apiKey must be a string")
76
+ }
77
+ if (source.model !== undefined && typeof source.model !== "string") {
78
+ warnings.push("model must be a string")
79
+ }
80
+ if (source.baseUrl !== undefined && source.baseUrl !== null && typeof source.baseUrl !== "string") {
81
+ warnings.push("baseUrl must be a string or null")
82
+ }
83
+ if ((provider ?? DEFAULT_PROVIDER) === "custom" && !baseUrl) {
84
+ warnings.push("custom provider requires a baseUrl")
85
+ }
86
+
87
+ const normalizedProvider = provider ?? DEFAULT_PROVIDER
88
+ const resolvedModel = model || resolveLlmProviderDefaultModel(normalizedProvider)
89
+ const resolvedBaseUrl = resolveLlmProviderBaseUrl(normalizedProvider, baseUrl)
90
+ const enabled = warnings.length === 0 && apiKey.length > 0 && resolvedModel.length > 0 && resolvedBaseUrl.length > 0
91
+
92
+ return {
93
+ provider: normalizedProvider,
94
+ apiKey,
95
+ model: resolvedModel,
96
+ baseUrl,
97
+ resolvedBaseUrl,
98
+ enabled,
99
+ warning: warnings.length > 0 ? `Some LLM provider settings are invalid: ${warnings.join("; ")}` : null,
100
+ filePathDisplay: formatDisplayPath(filePath),
101
+ }
102
+ }
103
+
104
+ function createDefaultSnapshot(filePath: string, warning: string | null = null): LlmProviderSnapshot {
105
+ return {
106
+ provider: DEFAULT_PROVIDER,
107
+ apiKey: "",
108
+ model: DEFAULT_OPENAI_SDK_MODEL,
109
+ baseUrl: "",
110
+ resolvedBaseUrl: OPENAI_BASE_URL,
111
+ enabled: false,
112
+ warning,
113
+ filePathDisplay: formatDisplayPath(filePath),
114
+ }
115
+ }
116
+
117
+ export async function readLlmProviderSnapshot(filePath = getLlmProviderFilePath(homedir())) {
118
+ try {
119
+ const text = await readFile(filePath, "utf8")
120
+ if (!text.trim()) {
121
+ return createDefaultSnapshot(filePath, "LLM provider file was empty. Using defaults.")
122
+ }
123
+ return normalizeLlmProviderSnapshot(JSON.parse(text), filePath)
124
+ } catch (error) {
125
+ if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
126
+ return createDefaultSnapshot(filePath)
127
+ }
128
+ if (error instanceof SyntaxError) {
129
+ return createDefaultSnapshot(filePath, "LLM provider file is invalid JSON. Using defaults.")
130
+ }
131
+ throw error
132
+ }
133
+ }
134
+
135
+ export async function writeLlmProviderSnapshot(
136
+ value: Pick<LlmProviderFile, "provider" | "apiKey" | "model"> & { baseUrl: string },
137
+ filePath = getLlmProviderFilePath(homedir())
138
+ ) {
139
+ const snapshot = normalizeLlmProviderSnapshot(value, filePath)
140
+ const payload: LlmProviderFile = {
141
+ provider: snapshot.provider,
142
+ apiKey: snapshot.apiKey,
143
+ model: snapshot.model,
144
+ baseUrl: snapshot.provider === "custom" ? snapshot.baseUrl : null,
145
+ }
146
+ await mkdir(path.dirname(filePath), { recursive: true })
147
+ await writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8")
148
+ return snapshot
149
+ }