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.
- package/dist/client/assets/index-DKU6KOsn.js +478 -0
- package/dist/client/index.html +1 -1
- package/package.json +1 -1
- 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/index.html
CHANGED
|
@@ -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-
|
|
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
|
@@ -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) {
|