kanna-code 0.32.1 → 0.32.3

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.
@@ -30,7 +30,7 @@
30
30
  })()
31
31
  </script>
32
32
  <title>Kanna</title>
33
- <script type="module" crossorigin src="/assets/index-EVYrrkP5.js"></script>
33
+ <script type="module" crossorigin src="/assets/index-BVeR75Xr.js"></script>
34
34
  <link rel="stylesheet" crossorigin href="/assets/index-B3EUEMfY.css">
35
35
  </head>
36
36
  <body>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kanna-code",
3
3
  "type": "module",
4
- "version": "0.32.1",
4
+ "version": "0.32.3",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -672,6 +672,161 @@ describe("CodexAppServerManager", () => {
672
672
  })
673
673
  })
674
674
 
675
+ test("maps plain-text fileChange adds into write_file tool calls", async () => {
676
+ const process = new FakeCodexProcess((message, child) => {
677
+ if (message.method === "initialize") {
678
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
679
+ } else if (message.method === "thread/start") {
680
+ child.writeServerMessage({
681
+ id: message.id,
682
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
683
+ })
684
+ } else if (message.method === "turn/start") {
685
+ child.writeServerMessage({
686
+ id: message.id,
687
+ result: { turn: { id: "turn-1", status: "inProgress", error: null } },
688
+ })
689
+ child.writeServerMessage({
690
+ method: "item/completed",
691
+ params: {
692
+ threadId: "thread-1",
693
+ turnId: "turn-1",
694
+ item: {
695
+ type: "fileChange",
696
+ id: "call-1",
697
+ changes: [
698
+ {
699
+ path: "/tmp/project/test.md",
700
+ kind: {
701
+ type: "add",
702
+ move_path: null,
703
+ },
704
+ diff: "hello\nworld\n",
705
+ },
706
+ ],
707
+ status: "completed",
708
+ },
709
+ },
710
+ })
711
+ child.writeServerMessage({
712
+ method: "turn/completed",
713
+ params: {
714
+ threadId: "thread-1",
715
+ turn: { id: "turn-1", status: "completed", error: null },
716
+ },
717
+ })
718
+ }
719
+ })
720
+
721
+ const manager = new CodexAppServerManager({
722
+ spawnProcess: () => process as never,
723
+ })
724
+
725
+ await manager.startSession({
726
+ chatId: "chat-1",
727
+ cwd: "/tmp/project",
728
+ model: "gpt-5.4",
729
+ sessionToken: null,
730
+ })
731
+
732
+ const turn = await manager.startTurn({
733
+ chatId: "chat-1",
734
+ model: "gpt-5.4",
735
+ content: "write a file",
736
+ planMode: false,
737
+ onToolRequest: async () => ({}),
738
+ })
739
+
740
+ const events = await collectStream(turn.stream)
741
+ const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call")
742
+
743
+ expect(toolCall?.entry.kind).toBe("tool_call")
744
+ if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call")
745
+ expect(toolCall.entry.tool.toolKind).toBe("write_file")
746
+ expect(toolCall.entry.tool.input).toEqual({
747
+ filePath: "/tmp/project/test.md",
748
+ content: "hello\nworld\n",
749
+ })
750
+ })
751
+
752
+ test("maps plain-text fileChange deletes into delete_file tool calls", async () => {
753
+ const process = new FakeCodexProcess((message, child) => {
754
+ if (message.method === "initialize") {
755
+ child.writeServerMessage({ id: message.id, result: { userAgent: "codex-test" } })
756
+ } else if (message.method === "thread/start") {
757
+ child.writeServerMessage({
758
+ id: message.id,
759
+ result: { thread: { id: "thread-1" }, model: "gpt-5.4", reasoningEffort: "high" },
760
+ })
761
+ } else if (message.method === "turn/start") {
762
+ child.writeServerMessage({
763
+ id: message.id,
764
+ result: { turn: { id: "turn-1", status: "inProgress", error: null } },
765
+ })
766
+ child.writeServerMessage({
767
+ method: "item/completed",
768
+ params: {
769
+ threadId: "thread-1",
770
+ turnId: "turn-1",
771
+ item: {
772
+ type: "fileChange",
773
+ id: "call-1",
774
+ changes: [
775
+ {
776
+ path: "/tmp/project/test.md",
777
+ kind: {
778
+ type: "delete",
779
+ move_path: null,
780
+ },
781
+ diff: "hello\nworld\n",
782
+ },
783
+ ],
784
+ status: "completed",
785
+ },
786
+ },
787
+ })
788
+ child.writeServerMessage({
789
+ method: "turn/completed",
790
+ params: {
791
+ threadId: "thread-1",
792
+ turn: { id: "turn-1", status: "completed", error: null },
793
+ },
794
+ })
795
+ }
796
+ })
797
+
798
+ const manager = new CodexAppServerManager({
799
+ spawnProcess: () => process as never,
800
+ })
801
+
802
+ await manager.startSession({
803
+ chatId: "chat-1",
804
+ cwd: "/tmp/project",
805
+ model: "gpt-5.4",
806
+ sessionToken: null,
807
+ })
808
+
809
+ const turn = await manager.startTurn({
810
+ chatId: "chat-1",
811
+ model: "gpt-5.4",
812
+ content: "delete a file",
813
+ planMode: false,
814
+ onToolRequest: async () => ({}),
815
+ })
816
+
817
+ const events = await collectStream(turn.stream)
818
+ const toolCall = events.find((event) => event.type === "transcript" && event.entry.kind === "tool_call")
819
+
820
+ expect(toolCall?.entry.kind).toBe("tool_call")
821
+ if (!toolCall || toolCall.entry.kind !== "tool_call") throw new Error("missing tool call")
822
+ expect(toolCall.entry.tool.toolKind).toBe("delete_file")
823
+ expect(toolCall.entry.tool.toolName).toBe("Delete")
824
+ expect(toolCall.entry.tool.input).toEqual({
825
+ filePath: "/tmp/project/test.md",
826
+ content: "hello\nworld\n",
827
+ })
828
+ })
829
+
675
830
  test("splits multi-change fileChange items into multiple tool calls and results", async () => {
676
831
  const process = new FakeCodexProcess((message, child) => {
677
832
  if (message.method === "initialize") {
@@ -430,6 +430,18 @@ function parseUnifiedDiff(diff: string): { oldString: string; newString: string
430
430
  }
431
431
  }
432
432
 
433
+ function isUnifiedDiff(diff: string) {
434
+ return diff.includes("@@")
435
+ || diff.startsWith("---")
436
+ || diff.startsWith("+++")
437
+ || diff.split(/\r?\n/).some((line) => (
438
+ line.startsWith("+")
439
+ || line.startsWith("-")
440
+ || line.startsWith(" ")
441
+ || line === "\"
442
+ ))
443
+ }
444
+
433
445
  function fileChangeToToolCalls(item: Extract<ThreadItem, { type: "fileChange" }>): TranscriptEntry[] {
434
446
  return item.changes.map((change, index) => {
435
447
  const payload = fileChangePayload(item, change)
@@ -453,7 +465,10 @@ function fileChangeToToolCalls(item: Extract<ThreadItem, { type: "fileChange" }>
453
465
  }
454
466
 
455
467
  if (typeof change.diff === "string") {
456
- const { oldString, newString } = parseUnifiedDiff(change.diff)
468
+ const diffIsUnified = isUnifiedDiff(change.diff)
469
+ const { oldString, newString } = diffIsUnified
470
+ ? parseUnifiedDiff(change.diff)
471
+ : { oldString: change.diff, newString: change.diff }
457
472
 
458
473
  if (normalizedKind.type === "add") {
459
474
  return timestamped({
@@ -473,6 +488,22 @@ function fileChangeToToolCalls(item: Extract<ThreadItem, { type: "fileChange" }>
473
488
  }
474
489
 
475
490
  if (normalizedKind.type === "update") {
491
+ if (!diffIsUnified) {
492
+ return timestamped({
493
+ kind: "tool_call",
494
+ tool: {
495
+ kind: "tool",
496
+ toolKind: "unknown_tool",
497
+ toolName: "FileChange",
498
+ toolId,
499
+ input: {
500
+ payload,
501
+ },
502
+ rawInput: payload,
503
+ },
504
+ })
505
+ }
506
+
476
507
  return timestamped({
477
508
  kind: "tool_call",
478
509
  tool: {
@@ -489,6 +520,23 @@ function fileChangeToToolCalls(item: Extract<ThreadItem, { type: "fileChange" }>
489
520
  },
490
521
  })
491
522
  }
523
+
524
+ if (normalizedKind.type === "delete") {
525
+ return timestamped({
526
+ kind: "tool_call",
527
+ tool: {
528
+ kind: "tool",
529
+ toolKind: "delete_file",
530
+ toolName: "Delete",
531
+ toolId,
532
+ input: {
533
+ filePath: change.path,
534
+ content: oldString,
535
+ },
536
+ rawInput: payload,
537
+ },
538
+ })
539
+ }
492
540
  }
493
541
 
494
542
  return timestamped({
@@ -270,6 +270,28 @@ describe("DiffStore", () => {
270
270
  })
271
271
  })
272
272
 
273
+ test("refreshSnapshot tolerates tracked files replaced by directories", async () => {
274
+ const repoRoot = await createRepo()
275
+ tempDirs.push(repoRoot)
276
+ await writeFile(path.join(repoRoot, "thing"), "base\n", "utf8")
277
+ await run(["git", "add", "."], repoRoot)
278
+ await run(["git", "commit", "-m", "init"], repoRoot)
279
+
280
+ await rm(path.join(repoRoot, "thing"), { force: true })
281
+ await mkdir(path.join(repoRoot, "thing"), { recursive: true })
282
+ await writeFile(path.join(repoRoot, "thing", "file.txt"), "nested\n", "utf8")
283
+
284
+ const store = new DiffStore(repoRoot)
285
+ await store.initialize()
286
+ await expect(store.refreshSnapshot("project-1", repoRoot)).resolves.toBe(true)
287
+
288
+ const snapshot = store.getProjectSnapshot("project-1")
289
+ expect(snapshot.status).toBe("ready")
290
+ expect(snapshot.files).toHaveLength(2)
291
+ expect(snapshot.files.map((file) => file.path)).toEqual(["thing", "thing/file.txt"])
292
+ expect(snapshot.files.map((file) => file.changeType)).toEqual(["deleted", "added"])
293
+ })
294
+
273
295
  test("discardFile reverts a tracked modified file", async () => {
274
296
  const repoRoot = await createRepo()
275
297
  tempDirs.push(repoRoot)
@@ -856,7 +856,8 @@ async function listDirtyPaths(repoRoot: string) {
856
856
 
857
857
  async function readWorktreeFile(repoRoot: string, relativePath: string): Promise<string | null> {
858
858
  const absolutePath = path.join(repoRoot, relativePath)
859
- if (!(await fileExists(absolutePath))) {
859
+ const fileInfo = await stat(absolutePath).catch(() => null)
860
+ if (!fileInfo?.isFile()) {
860
861
  return null
861
862
  }
862
863
 
@@ -181,7 +181,7 @@ describe("EventStore", () => {
181
181
  content: "second queued",
182
182
  attachments: [],
183
183
  provider: "claude",
184
- model: "sonnet",
184
+ model: "claude-sonnet-4-6",
185
185
  planMode: true,
186
186
  })
187
187
 
@@ -8,14 +8,14 @@ import { resolveClaudeApiModelId } from "../shared/types"
8
8
 
9
9
  describe("provider catalog normalization", () => {
10
10
  test("maps legacy Claude effort into shared model options", () => {
11
- expect(normalizeClaudeModelOptions("opus", undefined, "max")).toEqual({
11
+ expect(normalizeClaudeModelOptions("claude-opus-4-7", undefined, "max")).toEqual({
12
12
  reasoningEffort: "max",
13
13
  contextWindow: "200k",
14
14
  })
15
15
  })
16
16
 
17
17
  test("normalizes Claude context window only for supported models", () => {
18
- expect(normalizeClaudeModelOptions("sonnet", {
18
+ expect(normalizeClaudeModelOptions("claude-sonnet-4-6", {
19
19
  claude: {
20
20
  reasoningEffort: "medium",
21
21
  contextWindow: "1m",
@@ -25,7 +25,7 @@ describe("provider catalog normalization", () => {
25
25
  contextWindow: "1m",
26
26
  })
27
27
 
28
- expect(normalizeClaudeModelOptions("haiku", {
28
+ expect(normalizeClaudeModelOptions("claude-haiku-4-5-20251001", {
29
29
  claude: {
30
30
  reasoningEffort: "medium",
31
31
  contextWindow: "1m",
@@ -56,7 +56,7 @@ describe("provider catalog normalization", () => {
56
56
  })
57
57
 
58
58
  test("resolves Claude API model ids for 1m context window", () => {
59
- expect(resolveClaudeApiModelId("opus", "1m")).toBe("opus[1m]")
60
- expect(resolveClaudeApiModelId("sonnet", "200k")).toBe("sonnet")
59
+ expect(resolveClaudeApiModelId("claude-opus-4-7", "1m")).toBe("claude-opus-4-7[1m]")
60
+ expect(resolveClaudeApiModelId("claude-sonnet-4-6", "200k")).toBe("claude-sonnet-4-6")
61
61
  })
62
62
  })
@@ -98,7 +98,7 @@ export async function runClaudeStructured(args: Omit<StructuredQuickResponseArgs
98
98
  prompt: args.prompt,
99
99
  options: {
100
100
  cwd: args.cwd,
101
- model: "haiku",
101
+ model: "claude-haiku-4-5-20251001",
102
102
  tools: [],
103
103
  systemPrompt: "",
104
104
  effort: "low",
@@ -62,7 +62,7 @@ describe("read models", () => {
62
62
  attachments: [],
63
63
  createdAt: 2,
64
64
  provider: "claude",
65
- model: "sonnet",
65
+ model: "claude-sonnet-4-6",
66
66
  planMode: true,
67
67
  }])
68
68
 
@@ -119,6 +119,26 @@ export function isClaudeContextWindow(value: unknown): value is ClaudeContextWin
119
119
  return CLAUDE_CONTEXT_WINDOW_OPTIONS.some((option) => option.id === value)
120
120
  }
121
121
 
122
+ export function normalizeClaudeModelId(modelId?: string): string {
123
+ switch (modelId) {
124
+ case "opus":
125
+ case "claude-opus-4-7":
126
+ return "claude-opus-4-7"
127
+ case "sonnet":
128
+ case "claude-sonnet-4-6":
129
+ return "claude-sonnet-4-6"
130
+ case "haiku":
131
+ case "claude-haiku-4-5-20251001":
132
+ return "claude-haiku-4-5-20251001"
133
+ default:
134
+ return modelId ?? "claude-opus-4-7"
135
+ }
136
+ }
137
+
138
+ export function isClaudeOpusModelId(modelId: string): boolean {
139
+ return normalizeClaudeModelId(modelId).startsWith("claude-opus-")
140
+ }
141
+
122
142
  export interface ProviderCatalogEntry {
123
143
  id: AgentProvider
124
144
  label: string
@@ -133,13 +153,13 @@ export const PROVIDERS: ProviderCatalogEntry[] = [
133
153
  {
134
154
  id: "claude",
135
155
  label: "Claude",
136
- defaultModel: "sonnet",
156
+ defaultModel: "claude-sonnet-4-6",
137
157
  defaultEffort: "high",
138
158
  supportsPlanMode: true,
139
159
  models: [
140
- { id: "opus", label: "Opus", supportsEffort: true, contextWindowOptions: [...CLAUDE_CONTEXT_WINDOW_OPTIONS] },
141
- { id: "sonnet", label: "Sonnet", supportsEffort: true, contextWindowOptions: [...CLAUDE_CONTEXT_WINDOW_OPTIONS] },
142
- { id: "haiku", label: "Haiku", supportsEffort: true },
160
+ { id: "claude-opus-4-7", label: "Opus 4.7", supportsEffort: true, contextWindowOptions: [...CLAUDE_CONTEXT_WINDOW_OPTIONS] },
161
+ { id: "claude-sonnet-4-6", label: "Sonnet 4.6", supportsEffort: true, contextWindowOptions: [...CLAUDE_CONTEXT_WINDOW_OPTIONS] },
162
+ { id: "claude-haiku-4-5-20251001", label: "Haiku 4.5", supportsEffort: true },
143
163
  ],
144
164
  efforts: [...CLAUDE_REASONING_OPTIONS],
145
165
  },
@@ -420,6 +440,9 @@ export interface WriteFileToolCall
420
440
  export interface EditFileToolCall
421
441
  extends ToolCallBase<"edit_file", { filePath: string; oldString: string; newString: string }> { }
422
442
 
443
+ export interface DeleteFileToolCall
444
+ extends ToolCallBase<"delete_file", { filePath: string; content: string }> { }
445
+
423
446
  export interface SubagentTaskToolCall
424
447
  extends ToolCallBase<"subagent_task", { subagentType?: string }> { }
425
448
 
@@ -441,6 +464,7 @@ export type NormalizedToolCall =
441
464
  | ReadFileToolCall
442
465
  | WriteFileToolCall
443
466
  | EditFileToolCall
467
+ | DeleteFileToolCall
444
468
  | SubagentTaskToolCall
445
469
  | McpGenericToolCall
446
470
  | UnknownToolCall
@@ -783,6 +807,9 @@ export type HydratedWriteFileToolCall =
783
807
  export type HydratedEditFileToolCall =
784
808
  HydratedToolCallBase<"edit_file", EditFileToolCall["input"], unknown>
785
809
 
810
+ export type HydratedDeleteFileToolCall =
811
+ HydratedToolCallBase<"delete_file", DeleteFileToolCall["input"], unknown>
812
+
786
813
  export type HydratedSubagentTaskToolCall =
787
814
  HydratedToolCallBase<"subagent_task", SubagentTaskToolCall["input"], unknown>
788
815
 
@@ -804,6 +831,7 @@ export type HydratedToolCall =
804
831
  | HydratedReadFileToolCall
805
832
  | HydratedWriteFileToolCall
806
833
  | HydratedEditFileToolCall
834
+ | HydratedDeleteFileToolCall
807
835
  | HydratedSubagentTaskToolCall
808
836
  | HydratedMcpGenericToolCall
809
837
  | HydratedUnknownToolCall