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.
- package/dist/client/assets/index-C4BaFDD7.css +32 -0
- package/dist/client/assets/index-DPinj1Li.js +478 -0
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/server/cli-runtime.ts +5 -54
- package/src/server/external-open.ts +1 -15
- package/src/server/process-utils.ts +15 -0
- package/src/server/read-models.ts +1 -1
- package/src/server/terminal-manager.test.ts +115 -0
- package/src/server/terminal-manager.ts +41 -1
- package/dist/client/assets/index-Cxfl4RoI.js +0 -473
- package/dist/client/assets/index-DzxKYydf.css +0 -32
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
9
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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,5 +1,6 @@
|
|
|
1
1
|
import process from "node:process"
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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) {
|