kanna-code 0.5.0 → 0.5.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,7 +5,7 @@
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-Cxfl4RoI.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-DKU6KOsn.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-DzxKYydf.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "kanna-code",
3
3
  "type": "module",
4
- "version": "0.5.0",
4
+ "version": "0.5.1",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -47,7 +47,7 @@ export function deriveSidebarData(
47
47
  localPath: project.localPath,
48
48
  chats,
49
49
  }
50
- }).filter((group) => group.chats.length > 0)
50
+ })
51
51
 
52
52
  return { projectGroups }
53
53
  }
@@ -0,0 +1,115 @@
1
+ import { afterEach, beforeAll, describe, expect, test } from "bun:test"
2
+ import { mkdtemp, rm } from "node:fs/promises"
3
+ import os from "node:os"
4
+ import path from "node:path"
5
+ import { TerminalManager } from "./terminal-manager"
6
+
7
+ const SHELL_START_TIMEOUT_MS = 5_000
8
+ const COMMAND_TIMEOUT_MS = 5_000
9
+
10
+ const isSupportedPlatform = process.platform !== "win32" && typeof Bun.Terminal === "function"
11
+ const describeIfSupported = isSupportedPlatform ? describe : describe.skip
12
+
13
+ let tempProjectPath = ""
14
+
15
+ beforeAll(async () => {
16
+ if (!isSupportedPlatform) return
17
+ tempProjectPath = await mkdtemp(path.join(os.tmpdir(), "kanna-terminal-manager-"))
18
+ })
19
+
20
+ afterEach(async () => {
21
+ if (!tempProjectPath) return
22
+ await rm(tempProjectPath, { recursive: true, force: true })
23
+ tempProjectPath = await mkdtemp(path.join(os.tmpdir(), "kanna-terminal-manager-"))
24
+ })
25
+
26
+ async function waitFor(check: () => boolean, timeoutMs: number, intervalMs = 25) {
27
+ const startedAt = Date.now()
28
+ while (Date.now() - startedAt < timeoutMs) {
29
+ if (check()) return
30
+ await Bun.sleep(intervalMs)
31
+ }
32
+ throw new Error(`Timed out after ${timeoutMs}ms`)
33
+ }
34
+
35
+ async function createSession(terminalId: string) {
36
+ const manager = new TerminalManager()
37
+ let output = ""
38
+ manager.onEvent((event) => {
39
+ if (event.type === "terminal.output" && event.terminalId === terminalId) {
40
+ output += event.data
41
+ }
42
+ })
43
+
44
+ manager.createTerminal({
45
+ projectPath: tempProjectPath,
46
+ terminalId,
47
+ cols: 80,
48
+ rows: 24,
49
+ scrollback: 1_000,
50
+ })
51
+
52
+ manager.write(terminalId, "printf '__KANNA_READY__\\n'\r")
53
+ await waitFor(() => output.includes("__KANNA_READY__"), SHELL_START_TIMEOUT_MS)
54
+
55
+ return {
56
+ manager,
57
+ getOutput: () => output,
58
+ }
59
+ }
60
+
61
+ describeIfSupported("TerminalManager", () => {
62
+ test("ctrl+c interrupts the foreground job and keeps the shell alive", async () => {
63
+ const terminalId = "terminal-ctrl-c-foreground"
64
+ const { manager, getOutput } = await createSession(terminalId)
65
+
66
+ try {
67
+ manager.write(terminalId, 'python3 -c "import time; time.sleep(30)"\r')
68
+ await waitFor(() => getOutput().includes("time.sleep(30)"), COMMAND_TIMEOUT_MS)
69
+
70
+ manager.write(terminalId, "\x03")
71
+ manager.write(terminalId, "printf '__KANNA_AFTER_INT__\\n'\r")
72
+
73
+ await waitFor(() => getOutput().includes("__KANNA_AFTER_INT__"), COMMAND_TIMEOUT_MS)
74
+
75
+ const snapshot = manager.getSnapshot(terminalId)
76
+ expect(snapshot?.status).toBe("running")
77
+ expect(getOutput()).toContain("__KANNA_AFTER_INT__")
78
+ } finally {
79
+ manager.close(terminalId)
80
+ }
81
+ })
82
+
83
+ test("ctrl+c at an idle prompt does not exit the shell", async () => {
84
+ const terminalId = "terminal-ctrl-c-prompt"
85
+ const { manager, getOutput } = await createSession(terminalId)
86
+
87
+ try {
88
+ const before = getOutput()
89
+ manager.write(terminalId, "\x03")
90
+
91
+ await waitFor(() => getOutput().length > before.length, COMMAND_TIMEOUT_MS)
92
+
93
+ const snapshot = manager.getSnapshot(terminalId)
94
+ expect(snapshot?.status).toBe("running")
95
+ expect(getOutput().length).toBeGreaterThan(before.length)
96
+ } finally {
97
+ manager.close(terminalId)
98
+ }
99
+ })
100
+
101
+ test("ctrl+d preserves eof behavior", async () => {
102
+ const terminalId = "terminal-ctrl-d"
103
+ const { manager } = await createSession(terminalId)
104
+
105
+ try {
106
+ manager.write(terminalId, "\x04")
107
+
108
+ await waitFor(() => manager.getSnapshot(terminalId)?.status === "exited", COMMAND_TIMEOUT_MS)
109
+
110
+ expect(manager.getSnapshot(terminalId)?.exitCode).toBe(0)
111
+ } finally {
112
+ manager.close(terminalId)
113
+ }
114
+ })
115
+ })
@@ -100,6 +100,29 @@ function killTerminalProcessTree(subprocess: Bun.Subprocess | null) {
100
100
  }
101
101
  }
102
102
 
103
+ function signalTerminalProcessGroup(subprocess: Bun.Subprocess | null, signal: NodeJS.Signals) {
104
+ if (!subprocess) return false
105
+
106
+ const pid = subprocess.pid
107
+ if (typeof pid !== "number") return false
108
+
109
+ if (process.platform !== "win32") {
110
+ try {
111
+ process.kill(-pid, signal)
112
+ return true
113
+ } catch {
114
+ // Fall back to signaling only the shell if group signaling fails.
115
+ }
116
+ }
117
+
118
+ try {
119
+ subprocess.kill(signal)
120
+ return true
121
+ } catch {
122
+ return false
123
+ }
124
+ }
125
+
103
126
  export class TerminalManager {
104
127
  private readonly sessions = new Map<string, TerminalSession>()
105
128
  private readonly listeners = new Set<(event: TerminalEvent) => void>()
@@ -220,7 +243,24 @@ export class TerminalManager {
220
243
  write(terminalId: string, data: string) {
221
244
  const session = this.sessions.get(terminalId)
222
245
  if (!session || session.status === "exited") return
223
- session.terminal.write(data)
246
+
247
+ let cursor = 0
248
+
249
+ while (cursor < data.length) {
250
+ const ctrlCIndex = data.indexOf("\x03", cursor)
251
+
252
+ if (ctrlCIndex === -1) {
253
+ session.terminal.write(data.slice(cursor))
254
+ return
255
+ }
256
+
257
+ if (ctrlCIndex > cursor) {
258
+ session.terminal.write(data.slice(cursor, ctrlCIndex))
259
+ }
260
+
261
+ signalTerminalProcessGroup(session.process, "SIGINT")
262
+ cursor = ctrlCIndex + 1
263
+ }
224
264
  }
225
265
 
226
266
  resize(terminalId: string, cols: number, rows: number) {