kanna-code 0.23.1 → 0.25.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.
@@ -11,7 +11,6 @@ describe("generateCommitMessageDetailed", () => {
11
11
  files: [{
12
12
  path: "app.ts",
13
13
  changeType: "modified",
14
- isUntracked: false,
15
14
  patch: "diff --git a/app.ts b/app.ts\n--- a/app.ts\n+++ b/app.ts\n@@\n-old\n+new\n",
16
15
  }],
17
16
  },
@@ -39,7 +38,6 @@ describe("generateCommitMessageDetailed", () => {
39
38
  files: [{
40
39
  path: "src/feature.ts",
41
40
  changeType: "modified",
42
- isUntracked: false,
43
41
  patch: "diff --git a/src/feature.ts b/src/feature.ts\n",
44
42
  }],
45
43
  },
@@ -1,7 +1,12 @@
1
1
  import path from "node:path"
2
- import type { ChatDiffFile } from "../shared/types"
3
2
  import { QuickResponseAdapter } from "./quick-response"
4
3
 
4
+ interface CommitMessageFile {
5
+ path: string
6
+ changeType: "added" | "deleted" | "modified" | "renamed"
7
+ patch: string
8
+ }
9
+
5
10
  const COMMIT_MESSAGE_SCHEMA = {
6
11
  type: "object",
7
12
  properties: {
@@ -44,7 +49,7 @@ function sanitizeBody(value: unknown): string {
44
49
  return value.trim()
45
50
  }
46
51
 
47
- function fallbackSubject(files: ChatDiffFile[]) {
52
+ function fallbackSubject(files: CommitMessageFile[]) {
48
53
  if (files.length === 1) {
49
54
  const fileName = path.posix.basename(files[0]?.path ?? "file")
50
55
  const normalized = `Update ${fileName}`.replace(/\s+/g, " ").trim()
@@ -56,7 +61,7 @@ function fallbackSubject(files: ChatDiffFile[]) {
56
61
 
57
62
  function buildCommitMessagePrompt(args: {
58
63
  branchName?: string
59
- files: ChatDiffFile[]
64
+ files: CommitMessageFile[]
60
65
  }) {
61
66
  const fileList = args.files.map((file) => `${file.changeType}: ${file.path}`).join("\n")
62
67
  const combinedPatch = args.files.map((file) => file.patch).join("\n\n")
@@ -83,7 +88,7 @@ export async function generateCommitMessageDetailed(
83
88
  args: {
84
89
  cwd: string
85
90
  branchName?: string
86
- files: ChatDiffFile[]
91
+ files: CommitMessageFile[]
87
92
  },
88
93
  adapter = new QuickResponseAdapter()
89
94
  ): Promise<GenerateCommitMessageResult> {
@@ -38,6 +38,9 @@ describe("normalizeKeybindings", () => {
38
38
  openInFinder: ["Cmd+Alt+F"],
39
39
  openInEditor: ["Cmd+Shift+O"],
40
40
  addSplitTerminal: ["Cmd+Shift+J"],
41
+ jumpToSidebarChat: ["Cmd+Alt"],
42
+ createChatInCurrentProject: ["Cmd+Alt+N"],
43
+ openAddProject: ["Cmd+Alt+O"],
41
44
  }, TEST_FILE_PATH)
42
45
 
43
46
  expect(snapshot).toEqual({
@@ -47,6 +50,9 @@ describe("normalizeKeybindings", () => {
47
50
  openInFinder: ["cmd+alt+f"],
48
51
  openInEditor: ["cmd+shift+o"],
49
52
  addSplitTerminal: ["cmd+shift+j"],
53
+ jumpToSidebarChat: ["cmd+alt"],
54
+ createChatInCurrentProject: ["cmd+alt+n"],
55
+ openAddProject: ["cmd+alt+o"],
50
56
  },
51
57
  warning: null,
52
58
  filePathDisplay: TEST_FILE_PATH,
@@ -97,6 +103,9 @@ describe("KeybindingsManager", () => {
97
103
  openInFinder: ["Cmd+Alt+F"],
98
104
  openInEditor: ["Cmd+Shift+O"],
99
105
  addSplitTerminal: ["Cmd+Shift+J"],
106
+ jumpToSidebarChat: ["Cmd+Alt"],
107
+ createChatInCurrentProject: ["Cmd+Alt+N"],
108
+ openAddProject: ["Cmd+Alt+O"],
100
109
  })
101
110
 
102
111
  expect(snapshot).toEqual({
@@ -106,6 +115,9 @@ describe("KeybindingsManager", () => {
106
115
  openInFinder: ["cmd+alt+f"],
107
116
  openInEditor: ["cmd+shift+o"],
108
117
  addSplitTerminal: ["cmd+shift+j"],
118
+ jumpToSidebarChat: ["cmd+alt"],
119
+ createChatInCurrentProject: ["cmd+alt+n"],
120
+ openAddProject: ["cmd+alt+o"],
109
121
  },
110
122
  warning: null,
111
123
  filePathDisplay: filePath,
@@ -159,6 +159,9 @@ function createDefaultSnapshot(filePath: string, warning: string | null = null):
159
159
  openInFinder: [...DEFAULT_KEYBINDINGS.openInFinder],
160
160
  openInEditor: [...DEFAULT_KEYBINDINGS.openInEditor],
161
161
  addSplitTerminal: [...DEFAULT_KEYBINDINGS.addSplitTerminal],
162
+ jumpToSidebarChat: [...DEFAULT_KEYBINDINGS.jumpToSidebarChat],
163
+ createChatInCurrentProject: [...DEFAULT_KEYBINDINGS.createChatInCurrentProject],
164
+ openAddProject: [...DEFAULT_KEYBINDINGS.openAddProject],
162
165
  },
163
166
  warning,
164
167
  filePathDisplay: formatDisplayPath(filePath),
@@ -66,8 +66,7 @@ describe("read models", () => {
66
66
  olderCursor: null,
67
67
  recentLimit: 200,
68
68
  },
69
- }),
70
- () => ({ status: "unknown", files: [] })
69
+ })
71
70
  )
72
71
  expect(chat?.runtime.provider).toBe("claude")
73
72
  expect(chat?.history.recentLimit).toBe(200)
@@ -1,5 +1,4 @@
1
1
  import type {
2
- ChatDiffSnapshot,
3
2
  ChatRuntime,
4
3
  ChatSnapshot,
5
4
  KannaStatus,
@@ -105,8 +104,7 @@ export function deriveChatSnapshot(
105
104
  activeStatuses: Map<string, KannaStatus>,
106
105
  drainingChatIds: Set<string>,
107
106
  chatId: string,
108
- getMessages: (chatId: string) => Pick<ChatSnapshot, "messages" | "history">,
109
- getDiffs: (chatId: string) => ChatDiffSnapshot
107
+ getMessages: (chatId: string) => Pick<ChatSnapshot, "messages" | "history">
110
108
  ): ChatSnapshot | null {
111
109
  const chat = state.chatsById.get(chatId)
112
110
  if (!chat || chat.deletedAt) return null
@@ -131,7 +129,6 @@ export function deriveChatSnapshot(
131
129
  runtime,
132
130
  messages: transcript.messages,
133
131
  history: transcript.history,
134
- diffs: getDiffs(chat.id),
135
132
  availableProviders: [...SERVER_PROVIDERS],
136
133
  }
137
134
  }
@@ -22,6 +22,9 @@ const DEFAULT_KEYBINDINGS_SNAPSHOT: KeybindingsSnapshot = {
22
22
  openInFinder: ["cmd+alt+f"],
23
23
  openInEditor: ["cmd+shift+o"],
24
24
  addSplitTerminal: ["cmd+shift+j"],
25
+ jumpToSidebarChat: ["cmd+alt"],
26
+ createChatInCurrentProject: ["cmd+alt+n"],
27
+ openAddProject: ["cmd+alt+o"],
25
28
  },
26
29
  warning: null,
27
30
  filePathDisplay: "~/.kanna/keybindings.json",
@@ -176,6 +179,264 @@ describe("ws-router", () => {
176
179
  })
177
180
  })
178
181
 
182
+ test("subscribes to project git snapshots independently from chat snapshots", async () => {
183
+ const state = createEmptyState()
184
+ state.projectsById.set("project-1", {
185
+ id: "project-1",
186
+ localPath: "/tmp/project",
187
+ title: "Project",
188
+ createdAt: 1,
189
+ updatedAt: 1,
190
+ })
191
+
192
+ const router = createWsRouter({
193
+ store: {
194
+ state,
195
+ getProject: () => state.projectsById.get("project-1") ?? null,
196
+ } as never,
197
+ diffStore: {
198
+ getProjectSnapshot: () => ({
199
+ status: "ready",
200
+ branchName: "main",
201
+ files: [],
202
+ branchHistory: { entries: [] },
203
+ }),
204
+ refreshSnapshot: async () => false,
205
+ listBranches: async () => ({ recent: [], local: [], remote: [], pullRequests: [], pullRequestsStatus: "unavailable" }),
206
+ previewMergeBranch: async () => ({ currentBranchName: "main", targetBranchName: "feature/test", targetDisplayName: "feature/test", status: "mergeable", commitCount: 1, hasConflicts: false, message: "ready" }),
207
+ mergeBranch: async () => ({ ok: true, branchName: "main", snapshotChanged: false }),
208
+ syncBranch: async () => ({ ok: true, action: "fetch", snapshotChanged: false }),
209
+ checkoutBranch: async () => ({ ok: true, snapshotChanged: false }),
210
+ createBranch: async () => ({ ok: true, branchName: "main", snapshotChanged: false }),
211
+ generateCommitMessage: async () => ({ subject: "", body: "", usedFallback: true, failureMessage: null }),
212
+ commitFiles: async () => ({ ok: true, mode: "commit_only", pushed: false, snapshotChanged: false }),
213
+ discardFile: async () => ({ snapshotChanged: false }),
214
+ ignoreFile: async () => ({ snapshotChanged: false }),
215
+ readPatch: async () => ({ patch: "" }),
216
+ } as never,
217
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
218
+ terminals: {
219
+ getSnapshot: () => null,
220
+ onEvent: () => () => {},
221
+ } as never,
222
+ keybindings: {
223
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
224
+ onChange: () => () => {},
225
+ } as never,
226
+ refreshDiscovery: async () => [],
227
+ getDiscoveredProjects: () => [],
228
+ machineDisplayName: "Local Machine",
229
+ updateManager: null,
230
+ })
231
+ const ws = new FakeWebSocket()
232
+
233
+ await router.handleMessage(
234
+ ws as never,
235
+ JSON.stringify({
236
+ v: 1,
237
+ type: "subscribe",
238
+ id: "project-git-sub-1",
239
+ topic: { type: "project-git", projectId: "project-1" },
240
+ })
241
+ )
242
+
243
+ expect(ws.sent[0]).toEqual({
244
+ v: PROTOCOL_VERSION,
245
+ type: "snapshot",
246
+ id: "project-git-sub-1",
247
+ snapshot: {
248
+ type: "project-git",
249
+ data: {
250
+ status: "ready",
251
+ branchName: "main",
252
+ files: [],
253
+ branchHistory: { entries: [] },
254
+ },
255
+ },
256
+ })
257
+ })
258
+
259
+ test("reads diff patches through the project-scoped command", async () => {
260
+ const state = createEmptyState()
261
+ state.projectsById.set("project-1", {
262
+ id: "project-1",
263
+ localPath: "/tmp/project",
264
+ title: "Project",
265
+ createdAt: 1,
266
+ updatedAt: 1,
267
+ })
268
+
269
+ const router = createWsRouter({
270
+ store: {
271
+ state,
272
+ getProject: (projectId: string) => state.projectsById.get(projectId) ?? null,
273
+ } as never,
274
+ diffStore: {
275
+ getProjectSnapshot: () => null,
276
+ refreshSnapshot: async () => false,
277
+ listBranches: async () => ({ recent: [], local: [], remote: [], pullRequests: [], pullRequestsStatus: "unavailable" }),
278
+ previewMergeBranch: async () => ({ currentBranchName: "main", targetBranchName: "feature/test", targetDisplayName: "feature/test", status: "mergeable", commitCount: 1, hasConflicts: false, message: "ready" }),
279
+ mergeBranch: async () => ({ ok: true, branchName: "main", snapshotChanged: false }),
280
+ syncBranch: async () => ({ ok: true, action: "fetch", snapshotChanged: false }),
281
+ checkoutBranch: async () => ({ ok: true, snapshotChanged: false }),
282
+ createBranch: async () => ({ ok: true, branchName: "main", snapshotChanged: false }),
283
+ generateCommitMessage: async () => ({ subject: "", body: "", usedFallback: true, failureMessage: null }),
284
+ commitFiles: async () => ({ ok: true, mode: "commit_only", pushed: false, snapshotChanged: false }),
285
+ discardFile: async () => ({ snapshotChanged: false }),
286
+ ignoreFile: async () => ({ snapshotChanged: false }),
287
+ readPatch: async () => ({ patch: "diff --git a/app.txt b/app.txt" }),
288
+ } as never,
289
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
290
+ terminals: {
291
+ getSnapshot: () => null,
292
+ onEvent: () => () => {},
293
+ } as never,
294
+ keybindings: {
295
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
296
+ onChange: () => () => {},
297
+ } as never,
298
+ refreshDiscovery: async () => [],
299
+ getDiscoveredProjects: () => [],
300
+ machineDisplayName: "Local Machine",
301
+ updateManager: null,
302
+ })
303
+ const ws = new FakeWebSocket()
304
+
305
+ await router.handleMessage(
306
+ ws as never,
307
+ JSON.stringify({
308
+ v: 1,
309
+ type: "command",
310
+ id: "read-patch-1",
311
+ command: {
312
+ type: "project.readDiffPatch",
313
+ projectId: "project-1",
314
+ path: "app.txt",
315
+ },
316
+ })
317
+ )
318
+
319
+ expect(ws.sent[0]).toEqual({
320
+ v: PROTOCOL_VERSION,
321
+ type: "ack",
322
+ id: "read-patch-1",
323
+ result: { patch: "diff --git a/app.txt b/app.txt" },
324
+ })
325
+ })
326
+
327
+ test("routes merge preview and merge commands through the diff store", async () => {
328
+ const state = createEmptyState()
329
+ state.projectsById.set("project-1", {
330
+ id: "project-1",
331
+ localPath: "/tmp/project",
332
+ title: "Project",
333
+ createdAt: 1,
334
+ updatedAt: 1,
335
+ })
336
+ state.chatsById.set("chat-1", {
337
+ id: "chat-1",
338
+ projectId: "project-1",
339
+ title: "Chat",
340
+ createdAt: 1,
341
+ updatedAt: 1,
342
+ unread: false,
343
+ provider: null,
344
+ planMode: false,
345
+ sessionToken: null,
346
+ lastTurnOutcome: null,
347
+ })
348
+
349
+ const router = createWsRouter({
350
+ store: {
351
+ state,
352
+ getProject: (projectId: string) => state.projectsById.get(projectId) ?? null,
353
+ getChat: (chatId: string) => state.chatsById.get(chatId) ?? null,
354
+ } as never,
355
+ diffStore: {
356
+ getProjectSnapshot: () => ({ status: "ready", branchName: "main", files: [], branchHistory: { entries: [] } }),
357
+ refreshSnapshot: async () => false,
358
+ listBranches: async () => ({ recent: [], local: [], remote: [], pullRequests: [], pullRequestsStatus: "unavailable" }),
359
+ previewMergeBranch: async () => ({ currentBranchName: "main", targetBranchName: "feature/test", targetDisplayName: "feature/test", status: "mergeable", commitCount: 2, hasConflicts: false, message: "2 commits from feature/test will merge into main." }),
360
+ mergeBranch: async () => ({ ok: true, branchName: "main", snapshotChanged: true }),
361
+ syncBranch: async () => ({ ok: true, action: "fetch", snapshotChanged: false }),
362
+ checkoutBranch: async () => ({ ok: true, snapshotChanged: false }),
363
+ createBranch: async () => ({ ok: true, branchName: "main", snapshotChanged: false }),
364
+ generateCommitMessage: async () => ({ subject: "", body: "", usedFallback: true, failureMessage: null }),
365
+ commitFiles: async () => ({ ok: true, mode: "commit_only", pushed: false, snapshotChanged: false }),
366
+ discardFile: async () => ({ snapshotChanged: false }),
367
+ ignoreFile: async () => ({ snapshotChanged: false }),
368
+ readPatch: async () => ({ patch: "" }),
369
+ } as never,
370
+ agent: { getActiveStatuses: () => new Map(), getDrainingChatIds: () => new Set() } as never,
371
+ terminals: {
372
+ getSnapshot: () => null,
373
+ onEvent: () => () => {},
374
+ } as never,
375
+ keybindings: {
376
+ getSnapshot: () => DEFAULT_KEYBINDINGS_SNAPSHOT,
377
+ onChange: () => () => {},
378
+ } as never,
379
+ refreshDiscovery: async () => [],
380
+ getDiscoveredProjects: () => [],
381
+ machineDisplayName: "Local Machine",
382
+ updateManager: null,
383
+ })
384
+ const ws = new FakeWebSocket()
385
+
386
+ await router.handleMessage(
387
+ ws as never,
388
+ JSON.stringify({
389
+ v: 1,
390
+ type: "command",
391
+ id: "preview-merge-1",
392
+ command: {
393
+ type: "chat.previewMergeBranch",
394
+ chatId: "chat-1",
395
+ branch: { kind: "local", name: "feature/test" },
396
+ },
397
+ })
398
+ )
399
+
400
+ await router.handleMessage(
401
+ ws as never,
402
+ JSON.stringify({
403
+ v: 1,
404
+ type: "command",
405
+ id: "merge-1",
406
+ command: {
407
+ type: "chat.mergeBranch",
408
+ chatId: "chat-1",
409
+ branch: { kind: "local", name: "feature/test" },
410
+ },
411
+ })
412
+ )
413
+
414
+ expect(ws.sent[0]).toEqual({
415
+ v: PROTOCOL_VERSION,
416
+ type: "ack",
417
+ id: "preview-merge-1",
418
+ result: {
419
+ currentBranchName: "main",
420
+ targetBranchName: "feature/test",
421
+ targetDisplayName: "feature/test",
422
+ status: "mergeable",
423
+ commitCount: 2,
424
+ hasConflicts: false,
425
+ message: "2 commits from feature/test will merge into main.",
426
+ },
427
+ })
428
+ expect(ws.sent[1]).toEqual({
429
+ v: PROTOCOL_VERSION,
430
+ type: "ack",
431
+ id: "merge-1",
432
+ result: {
433
+ ok: true,
434
+ branchName: "main",
435
+ snapshotChanged: true,
436
+ },
437
+ })
438
+ })
439
+
179
440
  test("loads older chat history pages", async () => {
180
441
  const state = createEmptyState()
181
442
  state.projectsById.set("project-1", {
@@ -506,6 +767,9 @@ describe("ws-router", () => {
506
767
  openInFinder: ["cmd+shift+g"],
507
768
  openInEditor: ["cmd+shift+p"],
508
769
  addSplitTerminal: ["cmd+alt+j"],
770
+ jumpToSidebarChat: ["cmd+alt"],
771
+ createChatInCurrentProject: ["cmd+alt+n"],
772
+ openAddProject: ["cmd+alt+o"],
509
773
  },
510
774
  },
511
775
  })
@@ -523,6 +787,9 @@ describe("ws-router", () => {
523
787
  openInFinder: ["cmd+shift+g"],
524
788
  openInEditor: ["cmd+shift+p"],
525
789
  addSplitTerminal: ["cmd+alt+j"],
790
+ jumpToSidebarChat: ["cmd+alt"],
791
+ createChatInCurrentProject: ["cmd+alt+n"],
792
+ openAddProject: ["cmd+alt+o"],
526
793
  },
527
794
  warning: null,
528
795
  filePathDisplay: "~/.kanna/keybindings.json",
@@ -675,13 +942,14 @@ describe("ws-router", () => {
675
942
  lastTurnOutcome: null,
676
943
  })
677
944
 
678
- const discardCalls: Array<{ chatId: string; projectPath: string; path: string }> = []
945
+ const discardCalls: Array<{ projectId: string; projectPath: string; path: string }> = []
679
946
  const diffStore = {
680
- getSnapshot: () => ({ status: "ready" as const, files: [] }),
947
+ getProjectSnapshot: () => ({ status: "ready" as const, files: [], defaultBranchName: "main", originRepoSlug: "acme/repo", aheadCount: 0, behindCount: 0, lastFetchedAt: undefined }),
681
948
  refreshSnapshot: async () => false,
949
+ syncBranch: async () => ({ ok: true as const, action: "fetch" as const, snapshotChanged: false }),
682
950
  generateCommitMessage: async () => ({ subject: "", body: "" }),
683
951
  commitFiles: async () => ({ ok: true as const, mode: "commit_only" as const, pushed: false, snapshotChanged: false }),
684
- discardFile: async (args: { chatId: string; projectPath: string; path: string }) => {
952
+ discardFile: async (args: { projectId: string; projectPath: string; path: string }) => {
685
953
  discardCalls.push(args)
686
954
  return { snapshotChanged: true }
687
955
  },
@@ -738,7 +1006,7 @@ describe("ws-router", () => {
738
1006
  )
739
1007
 
740
1008
  expect(discardCalls).toEqual([{
741
- chatId: "chat-1",
1009
+ projectId: "project-1",
742
1010
  projectPath: "/tmp/project",
743
1011
  path: "app.txt",
744
1012
  }])
@@ -773,7 +1041,7 @@ describe("ws-router", () => {
773
1041
  lastTurnOutcome: null,
774
1042
  })
775
1043
 
776
- const ignoreCalls: Array<{ chatId: string; projectPath: string; path: string }> = []
1044
+ const ignoreCalls: Array<{ projectId: string; projectPath: string; path: string }> = []
777
1045
  const router = createWsRouter({
778
1046
  store: {
779
1047
  state,
@@ -781,12 +1049,13 @@ describe("ws-router", () => {
781
1049
  getProject: (projectId: string) => state.projectsById.get(projectId) ?? null,
782
1050
  } as never,
783
1051
  diffStore: {
784
- getSnapshot: () => ({ status: "ready" as const, files: [] }),
1052
+ getProjectSnapshot: () => ({ status: "ready" as const, files: [], defaultBranchName: "main", originRepoSlug: "acme/repo", aheadCount: 0, behindCount: 0, lastFetchedAt: undefined }),
785
1053
  refreshSnapshot: async () => false,
1054
+ syncBranch: async () => ({ ok: true as const, action: "fetch" as const, snapshotChanged: false }),
786
1055
  generateCommitMessage: async () => ({ subject: "", body: "" }),
787
1056
  commitFiles: async () => ({ ok: true as const, mode: "commit_only" as const, pushed: false, snapshotChanged: false }),
788
1057
  discardFile: async () => ({ snapshotChanged: false }),
789
- ignoreFile: async (args: { chatId: string; projectPath: string; path: string }) => {
1058
+ ignoreFile: async (args: { projectId: string; projectPath: string; path: string }) => {
790
1059
  ignoreCalls.push(args)
791
1060
  return { snapshotChanged: false }
792
1061
  },
@@ -822,7 +1091,7 @@ describe("ws-router", () => {
822
1091
  )
823
1092
 
824
1093
  expect(ignoreCalls).toEqual([{
825
- chatId: "chat-1",
1094
+ projectId: "project-1",
826
1095
  projectPath: "/tmp/project",
827
1096
  path: "scratch.log",
828
1097
  }])