kanna-code 0.5.0 → 0.6.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.
@@ -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-Cxfl4RoI.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-DzxKYydf.css">
8
+ <script type="module" crossorigin src="/assets/index-DPinj1Li.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-C4BaFDD7.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.5.0",
4
+ "version": "0.6.0",
5
5
  "description": "A beautiful web UI for Claude Code",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -1,5 +1,6 @@
1
1
  import process from "node:process"
2
- import { spawn, spawnSync } from "node:child_process"
2
+ import { spawnSync } from "node:child_process"
3
+ import { hasCommand, spawnDetached } from "./process-utils"
3
4
  import { APP_NAME, CLI_COMMAND, getDataDirDisplay, LOG_PREFIX, PACKAGE_NAME } from "../shared/branding"
4
5
  import { PROD_SERVER_PORT } from "../shared/ports"
5
6
 
@@ -193,65 +194,15 @@ export async function runCli(argv: string[], deps: CliRuntimeDeps): Promise<CliR
193
194
  }
194
195
  }
195
196
 
196
- function spawnDetached(command: string, args: string[]) {
197
- spawn(command, args, { stdio: "ignore", detached: true }).unref()
198
- }
199
-
200
- function hasCommand(command: string) {
201
- const result = spawnSync("sh", ["-lc", `command -v ${command}`], { stdio: "ignore" })
202
- return result.status === 0
203
- }
204
-
205
- function canOpenMacApp(appName: string) {
206
- const result = spawnSync("open", ["-Ra", appName], { stdio: "ignore" })
207
- return result.status === 0
208
- }
209
-
210
197
  export function openUrl(url: string) {
211
198
  const platform = process.platform
212
199
  if (platform === "darwin") {
213
- const appCandidates = [
214
- "Google Chrome",
215
- "Chromium",
216
- "Brave Browser",
217
- "Microsoft Edge",
218
- "Arc",
219
- ]
220
-
221
- for (const appName of appCandidates) {
222
- if (!canOpenMacApp(appName)) continue
223
- spawnDetached("open", ["-a", appName, "--args", `--app=${url}`])
224
- console.log(`${LOG_PREFIX} opened in app window via ${appName}`)
225
- return
226
- }
227
-
228
200
  spawnDetached("open", [url])
229
- console.log(`${LOG_PREFIX} opened in default browser`)
230
- return
231
- }
232
- if (platform === "win32") {
233
- const browserCommands = ["chrome", "msedge", "brave", "chromium"]
234
- for (const command of browserCommands) {
235
- if (!hasCommand(command)) continue
236
- spawnDetached(command, [`--app=${url}`])
237
- console.log(`${LOG_PREFIX} opened in app window via ${command}`)
238
- return
239
- }
240
-
201
+ } else if (platform === "win32") {
241
202
  spawnDetached("cmd", ["/c", "start", "", url])
242
- console.log(`${LOG_PREFIX} opened in default browser`)
243
- return
244
- }
245
-
246
- const browserCommands = ["google-chrome", "chromium", "brave-browser", "microsoft-edge"]
247
- for (const command of browserCommands) {
248
- if (!hasCommand(command)) continue
249
- spawnDetached(command, [`--app=${url}`])
250
- console.log(`${LOG_PREFIX} opened in app window via ${command}`)
251
- return
203
+ } else {
204
+ spawnDetached("xdg-open", [url])
252
205
  }
253
-
254
- spawnDetached("xdg-open", [url])
255
206
  console.log(`${LOG_PREFIX} opened in default browser`)
256
207
  }
257
208
 
@@ -1,24 +1,10 @@
1
1
  import process from "node:process"
2
- import { spawn, spawnSync } from "node:child_process"
3
2
  import type { ClientCommand } from "../shared/protocol"
4
3
  import { resolveLocalPath } from "./paths"
4
+ import { canOpenMacApp, hasCommand, spawnDetached } from "./process-utils"
5
5
 
6
6
  type OpenExternalAction = Extract<ClientCommand, { type: "system.openExternal" }>["action"]
7
7
 
8
- function spawnDetached(command: string, args: string[]) {
9
- spawn(command, args, { stdio: "ignore", detached: true }).unref()
10
- }
11
-
12
- function hasCommand(command: string) {
13
- const result = spawnSync("sh", ["-lc", `command -v ${command}`], { stdio: "ignore" })
14
- return result.status === 0
15
- }
16
-
17
- function canOpenMacApp(appName: string) {
18
- const result = spawnSync("open", ["-Ra", appName], { stdio: "ignore" })
19
- return result.status === 0
20
- }
21
-
22
8
  export function openExternal(localPath: string, action: OpenExternalAction) {
23
9
  const resolvedPath = resolveLocalPath(localPath)
24
10
  const platform = process.platform
@@ -0,0 +1,15 @@
1
+ import { spawn, spawnSync } from "node:child_process"
2
+
3
+ export function spawnDetached(command: string, args: string[]) {
4
+ spawn(command, args, { stdio: "ignore", detached: true }).unref()
5
+ }
6
+
7
+ export function hasCommand(command: string) {
8
+ const result = spawnSync("sh", ["-lc", `command -v ${command}`], { stdio: "ignore" })
9
+ return result.status === 0
10
+ }
11
+
12
+ export function canOpenMacApp(appName: string) {
13
+ const result = spawnSync("open", ["-Ra", appName], { stdio: "ignore" })
14
+ return result.status === 0
15
+ }
@@ -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) {