kanna-code 0.3.0 → 0.4.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.
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <link rel="icon" type="image/png" href="/favicon.png" />
7
7
  <title>Kanna</title>
8
- <script type="module" crossorigin src="/assets/index-Byzgv_-q.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-gld9RxCU.css">
8
+ <script type="module" crossorigin src="/assets/index-CcZLosQ7.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-BXpHm8Wa.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kanna-code",
3
3
  "type": "module",
4
- "version": "0.3.0",
4
+ "version": "0.4.1",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -42,7 +42,8 @@
42
42
  "prepublishOnly": "vite build"
43
43
  },
44
44
  "dependencies": {
45
- "@anthropic-ai/claude-agent-sdk": "^0.2.39"
45
+ "@anthropic-ai/claude-agent-sdk": "^0.2.39",
46
+ "@radix-ui/react-context-menu": "^2.2.16"
46
47
  },
47
48
  "devDependencies": {
48
49
  "@dnd-kit/core": "^6.3.1",
@@ -481,6 +481,176 @@ describe("AgentCoordinator codex integration", () => {
481
481
  expect(store.messages.some((entry) => entry.kind === "context_cleared")).toBe(true)
482
482
  expect(store.chat.sessionToken).toBe("thread-2")
483
483
  })
484
+
485
+ test("cancelling a waiting ask-user-question records a discarded tool result", async () => {
486
+ let releaseInterrupt!: () => void
487
+ const interrupted = new Promise<void>((resolve) => {
488
+ releaseInterrupt = resolve
489
+ })
490
+
491
+ const fakeCodexManager = {
492
+ async startSession() {},
493
+ async startTurn(args: {
494
+ onToolRequest: (request: any) => Promise<unknown>
495
+ }): Promise<HarnessTurn> {
496
+ async function* stream() {
497
+ yield {
498
+ type: "transcript" as const,
499
+ entry: timestamped({
500
+ kind: "system_init",
501
+ provider: "codex",
502
+ model: "gpt-5.4",
503
+ tools: [],
504
+ agents: [],
505
+ slashCommands: [],
506
+ mcpServers: [],
507
+ }),
508
+ }
509
+ void args.onToolRequest({
510
+ tool: {
511
+ kind: "tool",
512
+ toolKind: "ask_user_question",
513
+ toolName: "AskUserQuestion",
514
+ toolId: "question-1",
515
+ input: {
516
+ questions: [{ question: "Provider?" }],
517
+ },
518
+ },
519
+ })
520
+ await interrupted
521
+ }
522
+
523
+ return {
524
+ provider: "codex",
525
+ stream: stream(),
526
+ interrupt: async () => {
527
+ releaseInterrupt()
528
+ },
529
+ close: () => {},
530
+ }
531
+ },
532
+ }
533
+
534
+ const store = createFakeStore()
535
+ const coordinator = new AgentCoordinator({
536
+ store: store as never,
537
+ onStateChange: () => {},
538
+ codexManager: fakeCodexManager as never,
539
+ })
540
+
541
+ await coordinator.send({
542
+ type: "chat.send",
543
+ chatId: "chat-1",
544
+ provider: "codex",
545
+ content: "ask me something",
546
+ })
547
+
548
+ await waitFor(() => coordinator.getPendingTool("chat-1")?.toolKind === "ask_user_question")
549
+ await coordinator.cancel("chat-1")
550
+
551
+ const discardedResult = store.messages.find((entry) => entry.kind === "tool_result" && entry.toolId === "question-1")
552
+ expect(discardedResult).toBeDefined()
553
+ if (!discardedResult || discardedResult.kind !== "tool_result") {
554
+ throw new Error("missing discarded ask-user-question result")
555
+ }
556
+ expect(discardedResult.content).toEqual({ discarded: true, answers: {} })
557
+ expect(store.messages.some((entry) => entry.kind === "interrupted")).toBe(true)
558
+ })
559
+
560
+ test("cancelling a waiting codex exit-plan prompt discards it without starting a follow-up turn", async () => {
561
+ let releaseInterrupt!: () => void
562
+ const interrupted = new Promise<void>((resolve) => {
563
+ releaseInterrupt = resolve
564
+ })
565
+ const startTurnCalls: string[] = []
566
+
567
+ const fakeCodexManager = {
568
+ async startSession() {},
569
+ async startTurn(args: {
570
+ content: string
571
+ onToolRequest: (request: any) => Promise<unknown>
572
+ }): Promise<HarnessTurn> {
573
+ startTurnCalls.push(args.content)
574
+
575
+ async function* stream() {
576
+ yield {
577
+ type: "transcript" as const,
578
+ entry: timestamped({
579
+ kind: "system_init",
580
+ provider: "codex",
581
+ model: "gpt-5.4",
582
+ tools: [],
583
+ agents: [],
584
+ slashCommands: [],
585
+ mcpServers: [],
586
+ }),
587
+ }
588
+ yield {
589
+ type: "transcript" as const,
590
+ entry: timestamped({
591
+ kind: "tool_call",
592
+ tool: {
593
+ kind: "tool",
594
+ toolKind: "exit_plan_mode",
595
+ toolName: "ExitPlanMode",
596
+ toolId: "exit-1",
597
+ input: {
598
+ plan: "## Plan",
599
+ },
600
+ },
601
+ }),
602
+ }
603
+ await args.onToolRequest({
604
+ tool: {
605
+ kind: "tool",
606
+ toolKind: "exit_plan_mode",
607
+ toolName: "ExitPlanMode",
608
+ toolId: "exit-1",
609
+ input: {
610
+ plan: "## Plan",
611
+ },
612
+ },
613
+ })
614
+ await interrupted
615
+ }
616
+
617
+ return {
618
+ provider: "codex",
619
+ stream: stream(),
620
+ interrupt: async () => {
621
+ releaseInterrupt()
622
+ },
623
+ close: () => {},
624
+ }
625
+ },
626
+ }
627
+
628
+ const store = createFakeStore()
629
+ const coordinator = new AgentCoordinator({
630
+ store: store as never,
631
+ onStateChange: () => {},
632
+ codexManager: fakeCodexManager as never,
633
+ })
634
+
635
+ await coordinator.send({
636
+ type: "chat.send",
637
+ chatId: "chat-1",
638
+ provider: "codex",
639
+ content: "plan this",
640
+ planMode: true,
641
+ })
642
+
643
+ await waitFor(() => coordinator.getPendingTool("chat-1")?.toolKind === "exit_plan_mode")
644
+ await coordinator.cancel("chat-1")
645
+
646
+ const discardedResult = store.messages.find((entry) => entry.kind === "tool_result" && entry.toolId === "exit-1")
647
+ expect(discardedResult).toBeDefined()
648
+ if (!discardedResult || discardedResult.kind !== "tool_result") {
649
+ throw new Error("missing discarded exit-plan result")
650
+ }
651
+ expect(discardedResult.content).toEqual({ discarded: true })
652
+ expect(startTurnCalls).toEqual(["plan this"])
653
+ })
484
654
  })
485
655
 
486
656
  function createFakeStore() {
@@ -88,6 +88,21 @@ function stringFromUnknown(value: unknown) {
88
88
  }
89
89
  }
90
90
 
91
+ function discardedToolResult(
92
+ tool: NormalizedToolCall & { toolKind: "ask_user_question" | "exit_plan_mode" }
93
+ ) {
94
+ if (tool.toolKind === "ask_user_question") {
95
+ return {
96
+ discarded: true,
97
+ answers: {},
98
+ }
99
+ }
100
+
101
+ return {
102
+ discarded: true,
103
+ }
104
+ }
105
+
91
106
  export function normalizeClaudeStreamMessage(message: any): TranscriptEntry[] {
92
107
  const debugRaw = JSON.stringify(message)
93
108
  const messageId = typeof message.uuid === "string" ? message.uuid : undefined
@@ -613,8 +628,25 @@ export class AgentCoordinator {
613
628
  if (!active) return
614
629
 
615
630
  active.cancelRequested = true
631
+
632
+ const pendingTool = active.pendingTool
616
633
  active.pendingTool = null
617
634
 
635
+ if (pendingTool) {
636
+ const result = discardedToolResult(pendingTool.tool)
637
+ await this.store.appendMessage(
638
+ chatId,
639
+ timestamped({
640
+ kind: "tool_result",
641
+ toolId: pendingTool.toolUseId,
642
+ content: result,
643
+ })
644
+ )
645
+ if (active.provider === "codex" && pendingTool.tool.toolKind === "exit_plan_mode") {
646
+ pendingTool.resolve(result)
647
+ }
648
+ }
649
+
618
650
  await this.store.appendMessage(chatId, timestamped({ kind: "interrupted" }))
619
651
  await this.store.recordTurnCancelled(chatId)
620
652
  active.cancelRecorded = true
@@ -0,0 +1,188 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { compareVersions, parseArgs, runCli } from "./cli-runtime"
3
+
4
+ function createDeps(overrides: Partial<Parameters<typeof runCli>[1]> = {}) {
5
+ const calls = {
6
+ startServer: [] as Array<{ port: number; openBrowser: boolean; strictPort: boolean }>,
7
+ fetchLatestVersion: [] as string[],
8
+ installLatest: [] as string[],
9
+ relaunch: [] as Array<{ command: string; args: string[] }>,
10
+ openUrl: [] as string[],
11
+ log: [] as string[],
12
+ warn: [] as string[],
13
+ }
14
+
15
+ const deps: Parameters<typeof runCli>[1] = {
16
+ version: "0.3.0",
17
+ startServer: async (options) => {
18
+ calls.startServer.push(options)
19
+ return {
20
+ port: options.port,
21
+ stop: async () => {},
22
+ }
23
+ },
24
+ fetchLatestVersion: async (packageName) => {
25
+ calls.fetchLatestVersion.push(packageName)
26
+ return "0.3.0"
27
+ },
28
+ installLatest: (packageName) => {
29
+ calls.installLatest.push(packageName)
30
+ return true
31
+ },
32
+ relaunch: (command, args) => {
33
+ calls.relaunch.push({ command, args })
34
+ return 0
35
+ },
36
+ openUrl: (url) => {
37
+ calls.openUrl.push(url)
38
+ },
39
+ log: (message) => {
40
+ calls.log.push(message)
41
+ },
42
+ warn: (message) => {
43
+ calls.warn.push(message)
44
+ },
45
+ ...overrides,
46
+ }
47
+
48
+ return { calls, deps }
49
+ }
50
+
51
+ describe("parseArgs", () => {
52
+ test("parses runtime options", () => {
53
+ expect(parseArgs(["--port", "4000", "--no-open"])).toEqual({
54
+ kind: "run",
55
+ options: {
56
+ port: 4000,
57
+ openBrowser: false,
58
+ strictPort: false,
59
+ },
60
+ })
61
+ })
62
+
63
+ test("parses strict port mode", () => {
64
+ expect(parseArgs(["--strict-port"])).toEqual({
65
+ kind: "run",
66
+ options: {
67
+ port: 3210,
68
+ openBrowser: true,
69
+ strictPort: true,
70
+ },
71
+ })
72
+ })
73
+
74
+ test("returns version and help actions without running startup", () => {
75
+ expect(parseArgs(["--version"])).toEqual({ kind: "version" })
76
+ expect(parseArgs(["--help"])).toEqual({ kind: "help" })
77
+ })
78
+ })
79
+
80
+ describe("compareVersions", () => {
81
+ test("orders semver-like versions", () => {
82
+ expect(compareVersions("0.3.0", "0.3.0")).toBe(0)
83
+ expect(compareVersions("0.3.0", "0.3.1")).toBe(-1)
84
+ expect(compareVersions("1.0.0", "0.9.9")).toBe(1)
85
+ })
86
+ })
87
+
88
+ describe("runCli", () => {
89
+ test("skips update checks for --version", async () => {
90
+ const { calls, deps } = createDeps()
91
+
92
+ const result = await runCli(["--version"], deps)
93
+
94
+ expect(result).toEqual({ kind: "exited", code: 0 })
95
+ expect(calls.fetchLatestVersion).toEqual([])
96
+ expect(calls.startServer).toEqual([])
97
+ expect(calls.log).toEqual(["0.3.0"])
98
+ })
99
+
100
+ test("starts normally when no newer version exists", async () => {
101
+ const { calls, deps } = createDeps()
102
+
103
+ const result = await runCli(["--port", "4000", "--no-open"], deps)
104
+
105
+ expect(result.kind).toBe("started")
106
+ expect(calls.fetchLatestVersion).toEqual(["kanna-code"])
107
+ expect(calls.installLatest).toEqual([])
108
+ expect(calls.relaunch).toEqual([])
109
+ expect(calls.startServer).toEqual([{ port: 4000, openBrowser: false, strictPort: false }])
110
+ expect(calls.openUrl).toEqual([])
111
+ })
112
+
113
+ test("opens the root route in the browser", async () => {
114
+ const { calls, deps } = createDeps()
115
+
116
+ await runCli(["--port", "4000"], deps)
117
+
118
+ expect(calls.openUrl).toEqual(["http://localhost:4000"])
119
+ })
120
+
121
+ test("installs and relaunches when a newer version is available", async () => {
122
+ const { calls, deps } = createDeps({
123
+ fetchLatestVersion: async (packageName) => {
124
+ calls.fetchLatestVersion.push(packageName)
125
+ return "0.4.0"
126
+ },
127
+ })
128
+
129
+ const result = await runCli(["--port", "4000", "--no-open"], deps)
130
+
131
+ expect(result).toEqual({ kind: "exited", code: 0 })
132
+ expect(calls.installLatest).toEqual(["kanna-code"])
133
+ expect(calls.relaunch).toEqual([{ command: "kanna", args: ["--port", "4000", "--no-open"] }])
134
+ expect(calls.startServer).toEqual([])
135
+ })
136
+
137
+ test("falls back to current version when install fails", async () => {
138
+ const { calls, deps } = createDeps({
139
+ fetchLatestVersion: async (packageName) => {
140
+ calls.fetchLatestVersion.push(packageName)
141
+ return "0.4.0"
142
+ },
143
+ installLatest: (packageName) => {
144
+ calls.installLatest.push(packageName)
145
+ return false
146
+ },
147
+ })
148
+
149
+ const result = await runCli(["--no-open"], deps)
150
+
151
+ expect(result.kind).toBe("started")
152
+ expect(calls.installLatest).toEqual(["kanna-code"])
153
+ expect(calls.relaunch).toEqual([])
154
+ expect(calls.warn).toContain("[kanna] update failed, continuing current version")
155
+ })
156
+
157
+ test("falls back to current version when the registry check fails", async () => {
158
+ const { calls, deps } = createDeps({
159
+ fetchLatestVersion: async (packageName) => {
160
+ calls.fetchLatestVersion.push(packageName)
161
+ throw new Error("network unavailable")
162
+ },
163
+ })
164
+
165
+ const result = await runCli(["--no-open"], deps)
166
+
167
+ expect(result.kind).toBe("started")
168
+ expect(calls.installLatest).toEqual([])
169
+ expect(calls.relaunch).toEqual([])
170
+ expect(calls.warn).toContain("[kanna] update check failed, continuing current version")
171
+ })
172
+
173
+ test("preserves original argv when relaunching", async () => {
174
+ const { calls, deps } = createDeps({
175
+ fetchLatestVersion: async (packageName) => {
176
+ calls.fetchLatestVersion.push(packageName)
177
+ return "0.4.0"
178
+ },
179
+ })
180
+
181
+ await runCli(["--port", "4567", "--no-open"], deps)
182
+
183
+ expect(calls.relaunch[0]).toEqual({
184
+ command: "kanna",
185
+ args: ["--port", "4567", "--no-open"],
186
+ })
187
+ })
188
+ })