kanna-code 0.24.0 → 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,14 +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: [], defaultBranchName: "main", originRepoSlug: "acme/repo", aheadCount: 0, behindCount: 0, lastFetchedAt: undefined }),
947
+ getProjectSnapshot: () => ({ status: "ready" as const, files: [], defaultBranchName: "main", originRepoSlug: "acme/repo", aheadCount: 0, behindCount: 0, lastFetchedAt: undefined }),
681
948
  refreshSnapshot: async () => false,
682
949
  syncBranch: async () => ({ ok: true as const, action: "fetch" as const, snapshotChanged: false }),
683
950
  generateCommitMessage: async () => ({ subject: "", body: "" }),
684
951
  commitFiles: async () => ({ ok: true as const, mode: "commit_only" as const, pushed: false, snapshotChanged: false }),
685
- discardFile: async (args: { chatId: string; projectPath: string; path: string }) => {
952
+ discardFile: async (args: { projectId: string; projectPath: string; path: string }) => {
686
953
  discardCalls.push(args)
687
954
  return { snapshotChanged: true }
688
955
  },
@@ -739,7 +1006,7 @@ describe("ws-router", () => {
739
1006
  )
740
1007
 
741
1008
  expect(discardCalls).toEqual([{
742
- chatId: "chat-1",
1009
+ projectId: "project-1",
743
1010
  projectPath: "/tmp/project",
744
1011
  path: "app.txt",
745
1012
  }])
@@ -774,7 +1041,7 @@ describe("ws-router", () => {
774
1041
  lastTurnOutcome: null,
775
1042
  })
776
1043
 
777
- const ignoreCalls: Array<{ chatId: string; projectPath: string; path: string }> = []
1044
+ const ignoreCalls: Array<{ projectId: string; projectPath: string; path: string }> = []
778
1045
  const router = createWsRouter({
779
1046
  store: {
780
1047
  state,
@@ -782,13 +1049,13 @@ describe("ws-router", () => {
782
1049
  getProject: (projectId: string) => state.projectsById.get(projectId) ?? null,
783
1050
  } as never,
784
1051
  diffStore: {
785
- getSnapshot: () => ({ status: "ready" as const, files: [], defaultBranchName: "main", originRepoSlug: "acme/repo", aheadCount: 0, behindCount: 0, lastFetchedAt: undefined }),
1052
+ getProjectSnapshot: () => ({ status: "ready" as const, files: [], defaultBranchName: "main", originRepoSlug: "acme/repo", aheadCount: 0, behindCount: 0, lastFetchedAt: undefined }),
786
1053
  refreshSnapshot: async () => false,
787
1054
  syncBranch: async () => ({ ok: true as const, action: "fetch" as const, snapshotChanged: false }),
788
1055
  generateCommitMessage: async () => ({ subject: "", body: "" }),
789
1056
  commitFiles: async () => ({ ok: true as const, mode: "commit_only" as const, pushed: false, snapshotChanged: false }),
790
1057
  discardFile: async () => ({ snapshotChanged: false }),
791
- ignoreFile: async (args: { chatId: string; projectPath: string; path: string }) => {
1058
+ ignoreFile: async (args: { projectId: string; projectPath: string; path: string }) => {
792
1059
  ignoreCalls.push(args)
793
1060
  return { snapshotChanged: false }
794
1061
  },
@@ -824,7 +1091,7 @@ describe("ws-router", () => {
824
1091
  )
825
1092
 
826
1093
  expect(ignoreCalls).toEqual([{
827
- chatId: "chat-1",
1094
+ projectId: "project-1",
828
1095
  projectPath: "/tmp/project",
829
1096
  path: "scratch.log",
830
1097
  }])