kanna-code 0.6.0 → 0.7.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-BQedIo87.css +32 -0
- package/dist/client/assets/index-BnsoKj0W.js +498 -0
- package/dist/client/index.html +2 -2
- package/package.json +2 -1
- package/src/server/external-open.test.ts +60 -0
- package/src/server/external-open.ts +203 -50
- package/src/server/terminal-manager.test.ts +162 -0
- package/src/server/terminal-manager.ts +36 -5
- package/src/server/ws-router.ts +1 -1
- package/src/shared/protocol.ts +15 -1
- package/dist/client/assets/index-C4BaFDD7.css +0 -32
- package/dist/client/assets/index-DPinj1Li.js +0 -478
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-BnsoKj0W.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BQedIo87.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.
|
|
4
|
+
"version": "0.7.0",
|
|
5
5
|
"description": "A beautiful web UI for Claude Code",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"@radix-ui/react-context-menu": "^2.2.16",
|
|
47
47
|
"@xterm/addon-fit": "^0.11.0",
|
|
48
48
|
"@xterm/addon-serialize": "^0.14.0",
|
|
49
|
+
"@xterm/addon-web-links": "^0.12.0",
|
|
49
50
|
"@xterm/headless": "^6.0.0",
|
|
50
51
|
"@xterm/xterm": "^6.0.0",
|
|
51
52
|
"default-shell": "^2.2.0",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { buildEditorCommand, tokenizeCommandTemplate } from "./external-open"
|
|
3
|
+
|
|
4
|
+
describe("tokenizeCommandTemplate", () => {
|
|
5
|
+
test("keeps quoted arguments together", () => {
|
|
6
|
+
expect(tokenizeCommandTemplate('code --reuse-window "{path}"')).toEqual([
|
|
7
|
+
"code",
|
|
8
|
+
"--reuse-window",
|
|
9
|
+
"{path}",
|
|
10
|
+
])
|
|
11
|
+
})
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe("buildEditorCommand", () => {
|
|
15
|
+
test("builds a preset goto command for file links", () => {
|
|
16
|
+
expect(
|
|
17
|
+
buildEditorCommand({
|
|
18
|
+
localPath: "/Users/jake/Projects/kanna/src/client/app/App.tsx",
|
|
19
|
+
isDirectory: false,
|
|
20
|
+
line: 12,
|
|
21
|
+
column: 3,
|
|
22
|
+
editor: { preset: "vscode", commandTemplate: "code {path}" },
|
|
23
|
+
platform: "linux",
|
|
24
|
+
})
|
|
25
|
+
).toEqual({
|
|
26
|
+
command: "code",
|
|
27
|
+
args: ["--goto", "/Users/jake/Projects/kanna/src/client/app/App.tsx:12:3"],
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test("builds a preset project command for directory opens", () => {
|
|
32
|
+
expect(
|
|
33
|
+
buildEditorCommand({
|
|
34
|
+
localPath: "/Users/jake/Projects/kanna",
|
|
35
|
+
isDirectory: true,
|
|
36
|
+
editor: { preset: "cursor", commandTemplate: "cursor {path}" },
|
|
37
|
+
platform: "linux",
|
|
38
|
+
})
|
|
39
|
+
).toEqual({
|
|
40
|
+
command: "cursor",
|
|
41
|
+
args: ["/Users/jake/Projects/kanna"],
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test("uses the custom template for editor opens", () => {
|
|
46
|
+
expect(
|
|
47
|
+
buildEditorCommand({
|
|
48
|
+
localPath: "/Users/jake/Projects/kanna/src/client/app/App.tsx",
|
|
49
|
+
isDirectory: false,
|
|
50
|
+
line: 12,
|
|
51
|
+
column: 1,
|
|
52
|
+
editor: { preset: "custom", commandTemplate: 'my-editor "{path}" --line {line}' },
|
|
53
|
+
platform: "linux",
|
|
54
|
+
})
|
|
55
|
+
).toEqual({
|
|
56
|
+
command: "my-editor",
|
|
57
|
+
args: ["/Users/jake/Projects/kanna/src/client/app/App.tsx", "--line", "12"],
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -1,44 +1,59 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises"
|
|
1
2
|
import process from "node:process"
|
|
2
|
-
import type { ClientCommand } from "../shared/protocol"
|
|
3
|
+
import type { ClientCommand, EditorOpenSettings, EditorPreset } from "../shared/protocol"
|
|
3
4
|
import { resolveLocalPath } from "./paths"
|
|
4
5
|
import { canOpenMacApp, hasCommand, spawnDetached } from "./process-utils"
|
|
5
6
|
|
|
6
|
-
type
|
|
7
|
+
type OpenExternalCommand = Extract<ClientCommand, { type: "system.openExternal" }>
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
interface CommandSpec {
|
|
10
|
+
command: string
|
|
11
|
+
args: string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const DEFAULT_EDITOR_SETTINGS: EditorOpenSettings = {
|
|
15
|
+
preset: "cursor",
|
|
16
|
+
commandTemplate: "cursor {path}",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function openExternal(command: OpenExternalCommand) {
|
|
20
|
+
const resolvedPath = resolveLocalPath(command.localPath)
|
|
10
21
|
const platform = process.platform
|
|
11
22
|
|
|
23
|
+
if (command.action === "open_editor") {
|
|
24
|
+
const info = await stat(resolvedPath).catch(() => null)
|
|
25
|
+
if (!info) {
|
|
26
|
+
throw new Error(`Path not found: ${resolvedPath}`)
|
|
27
|
+
}
|
|
28
|
+
const editorCommand = buildEditorCommand({
|
|
29
|
+
localPath: resolvedPath,
|
|
30
|
+
isDirectory: info.isDirectory(),
|
|
31
|
+
line: command.line,
|
|
32
|
+
column: command.column,
|
|
33
|
+
editor: command.editor ?? DEFAULT_EDITOR_SETTINGS,
|
|
34
|
+
platform,
|
|
35
|
+
})
|
|
36
|
+
spawnDetached(editorCommand.command, editorCommand.args)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
12
40
|
if (platform === "darwin") {
|
|
13
|
-
if (action === "open_finder") {
|
|
41
|
+
if (command.action === "open_finder") {
|
|
14
42
|
spawnDetached("open", [resolvedPath])
|
|
15
43
|
return
|
|
16
44
|
}
|
|
17
|
-
if (action === "open_terminal") {
|
|
45
|
+
if (command.action === "open_terminal") {
|
|
18
46
|
spawnDetached("open", ["-a", "Terminal", resolvedPath])
|
|
19
47
|
return
|
|
20
48
|
}
|
|
21
|
-
if (action === "open_editor") {
|
|
22
|
-
if (hasCommand("cursor")) {
|
|
23
|
-
spawnDetached("cursor", [resolvedPath])
|
|
24
|
-
return
|
|
25
|
-
}
|
|
26
|
-
for (const appName of ["Cursor", "Visual Studio Code", "Windsurf"]) {
|
|
27
|
-
if (!canOpenMacApp(appName)) continue
|
|
28
|
-
spawnDetached("open", ["-a", appName, resolvedPath])
|
|
29
|
-
return
|
|
30
|
-
}
|
|
31
|
-
spawnDetached("open", [resolvedPath])
|
|
32
|
-
return
|
|
33
|
-
}
|
|
34
49
|
}
|
|
35
50
|
|
|
36
51
|
if (platform === "win32") {
|
|
37
|
-
if (action === "open_finder") {
|
|
52
|
+
if (command.action === "open_finder") {
|
|
38
53
|
spawnDetached("explorer", [resolvedPath])
|
|
39
54
|
return
|
|
40
55
|
}
|
|
41
|
-
if (action === "open_terminal") {
|
|
56
|
+
if (command.action === "open_terminal") {
|
|
42
57
|
if (hasCommand("wt")) {
|
|
43
58
|
spawnDetached("wt", ["-d", resolvedPath])
|
|
44
59
|
return
|
|
@@ -46,46 +61,184 @@ export function openExternal(localPath: string, action: OpenExternalAction) {
|
|
|
46
61
|
spawnDetached("cmd", ["/c", "start", "", "cmd", "/K", `cd /d ${resolvedPath}`])
|
|
47
62
|
return
|
|
48
63
|
}
|
|
49
|
-
if (action === "open_editor") {
|
|
50
|
-
if (hasCommand("cursor")) {
|
|
51
|
-
spawnDetached("cursor", [resolvedPath])
|
|
52
|
-
return
|
|
53
|
-
}
|
|
54
|
-
if (hasCommand("code")) {
|
|
55
|
-
spawnDetached("code", [resolvedPath])
|
|
56
|
-
return
|
|
57
|
-
}
|
|
58
|
-
spawnDetached("explorer", [resolvedPath])
|
|
59
|
-
return
|
|
60
|
-
}
|
|
61
64
|
}
|
|
62
65
|
|
|
63
|
-
if (action === "open_finder") {
|
|
66
|
+
if (command.action === "open_finder") {
|
|
64
67
|
spawnDetached("xdg-open", [resolvedPath])
|
|
65
68
|
return
|
|
66
69
|
}
|
|
67
|
-
if (action === "open_terminal") {
|
|
68
|
-
for (const
|
|
69
|
-
if (!hasCommand(
|
|
70
|
-
if (
|
|
71
|
-
spawnDetached(
|
|
72
|
-
} else if (
|
|
73
|
-
spawnDetached(
|
|
70
|
+
if (command.action === "open_terminal") {
|
|
71
|
+
for (const terminalCommand of ["x-terminal-emulator", "gnome-terminal", "konsole"]) {
|
|
72
|
+
if (!hasCommand(terminalCommand)) continue
|
|
73
|
+
if (terminalCommand === "gnome-terminal") {
|
|
74
|
+
spawnDetached(terminalCommand, ["--working-directory", resolvedPath])
|
|
75
|
+
} else if (terminalCommand === "konsole") {
|
|
76
|
+
spawnDetached(terminalCommand, ["--workdir", resolvedPath])
|
|
74
77
|
} else {
|
|
75
|
-
spawnDetached(
|
|
78
|
+
spawnDetached(terminalCommand, ["--working-directory", resolvedPath])
|
|
76
79
|
}
|
|
77
80
|
return
|
|
78
81
|
}
|
|
79
82
|
spawnDetached("xdg-open", [resolvedPath])
|
|
80
|
-
return
|
|
81
83
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function buildEditorCommand(args: {
|
|
87
|
+
localPath: string
|
|
88
|
+
isDirectory: boolean
|
|
89
|
+
line?: number
|
|
90
|
+
column?: number
|
|
91
|
+
editor: EditorOpenSettings
|
|
92
|
+
platform: NodeJS.Platform
|
|
93
|
+
}): CommandSpec {
|
|
94
|
+
const editor = normalizeEditorSettings(args.editor)
|
|
95
|
+
if (editor.preset === "custom") {
|
|
96
|
+
return buildCustomEditorCommand({
|
|
97
|
+
commandTemplate: editor.commandTemplate,
|
|
98
|
+
localPath: args.localPath,
|
|
99
|
+
line: args.line,
|
|
100
|
+
column: args.column,
|
|
101
|
+
})
|
|
85
102
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
103
|
+
return buildPresetEditorCommand(args, editor.preset)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildPresetEditorCommand(
|
|
107
|
+
args: {
|
|
108
|
+
localPath: string
|
|
109
|
+
isDirectory: boolean
|
|
110
|
+
line?: number
|
|
111
|
+
column?: number
|
|
112
|
+
platform: NodeJS.Platform
|
|
113
|
+
},
|
|
114
|
+
preset: Exclude<EditorPreset, "custom">
|
|
115
|
+
): CommandSpec {
|
|
116
|
+
const gotoTarget = `${args.localPath}:${args.line ?? 1}:${args.column ?? 1}`
|
|
117
|
+
const opener = resolveEditorExecutable(preset, args.platform)
|
|
118
|
+
if (args.isDirectory || !args.line) {
|
|
119
|
+
return { command: opener.command, args: [...opener.args, args.localPath] }
|
|
120
|
+
}
|
|
121
|
+
return { command: opener.command, args: [...opener.args, "--goto", gotoTarget] }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function resolveEditorExecutable(preset: Exclude<EditorPreset, "custom">, platform: NodeJS.Platform) {
|
|
125
|
+
if (preset === "cursor") {
|
|
126
|
+
if (hasCommand("cursor")) return { command: "cursor", args: [] }
|
|
127
|
+
if (platform === "darwin" && canOpenMacApp("Cursor")) return { command: "open", args: ["-a", "Cursor"] }
|
|
128
|
+
}
|
|
129
|
+
if (preset === "vscode") {
|
|
130
|
+
if (hasCommand("code")) return { command: "code", args: [] }
|
|
131
|
+
if (platform === "darwin" && canOpenMacApp("Visual Studio Code")) return { command: "open", args: ["-a", "Visual Studio Code"] }
|
|
132
|
+
}
|
|
133
|
+
if (preset === "windsurf") {
|
|
134
|
+
if (hasCommand("windsurf")) return { command: "windsurf", args: [] }
|
|
135
|
+
if (platform === "darwin" && canOpenMacApp("Windsurf")) return { command: "open", args: ["-a", "Windsurf"] }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (platform === "darwin") {
|
|
139
|
+
switch (preset) {
|
|
140
|
+
case "cursor":
|
|
141
|
+
return { command: "open", args: ["-a", "Cursor"] }
|
|
142
|
+
case "vscode":
|
|
143
|
+
return { command: "open", args: ["-a", "Visual Studio Code"] }
|
|
144
|
+
case "windsurf":
|
|
145
|
+
return { command: "open", args: ["-a", "Windsurf"] }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { command: preset === "vscode" ? "code" : preset, args: [] }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function buildCustomEditorCommand(args: {
|
|
153
|
+
commandTemplate: string
|
|
154
|
+
localPath: string
|
|
155
|
+
line?: number
|
|
156
|
+
column?: number
|
|
157
|
+
}): CommandSpec {
|
|
158
|
+
const template = args.commandTemplate.trim()
|
|
159
|
+
if (!template.includes("{path}")) {
|
|
160
|
+
throw new Error("Custom editor command must include {path}")
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const line = String(args.line ?? 1)
|
|
164
|
+
const column = String(args.column ?? 1)
|
|
165
|
+
const replaced = template
|
|
166
|
+
.replaceAll("{path}", args.localPath)
|
|
167
|
+
.replaceAll("{line}", line)
|
|
168
|
+
.replaceAll("{column}", column)
|
|
169
|
+
|
|
170
|
+
const tokens = tokenizeCommandTemplate(replaced)
|
|
171
|
+
const [command, ...commandArgs] = tokens
|
|
172
|
+
if (!command) {
|
|
173
|
+
throw new Error("Custom editor command is empty")
|
|
174
|
+
}
|
|
175
|
+
return { command, args: commandArgs }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function tokenizeCommandTemplate(template: string) {
|
|
179
|
+
const tokens: string[] = []
|
|
180
|
+
let current = ""
|
|
181
|
+
let quote: "'" | "\"" | null = null
|
|
182
|
+
|
|
183
|
+
for (let index = 0; index < template.length; index += 1) {
|
|
184
|
+
const char = template[index]
|
|
185
|
+
|
|
186
|
+
if (char === "\\" && index + 1 < template.length) {
|
|
187
|
+
current += template[index + 1]
|
|
188
|
+
index += 1
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (quote) {
|
|
193
|
+
if (char === quote) {
|
|
194
|
+
quote = null
|
|
195
|
+
} else {
|
|
196
|
+
current += char
|
|
197
|
+
}
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (char === "'" || char === "\"") {
|
|
202
|
+
quote = char
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (/\s/.test(char)) {
|
|
207
|
+
if (current.length > 0) {
|
|
208
|
+
tokens.push(current)
|
|
209
|
+
current = ""
|
|
210
|
+
}
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
current += char
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (quote) {
|
|
218
|
+
throw new Error("Custom editor command has an unclosed quote")
|
|
219
|
+
}
|
|
220
|
+
if (current.length > 0) {
|
|
221
|
+
tokens.push(current)
|
|
222
|
+
}
|
|
223
|
+
return tokens
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function normalizeEditorSettings(editor: EditorOpenSettings): EditorOpenSettings {
|
|
227
|
+
const preset = normalizeEditorPreset(editor.preset)
|
|
228
|
+
return {
|
|
229
|
+
preset,
|
|
230
|
+
commandTemplate: editor.commandTemplate.trim() || DEFAULT_EDITOR_SETTINGS.commandTemplate,
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function normalizeEditorPreset(preset: EditorPreset): EditorPreset {
|
|
235
|
+
switch (preset) {
|
|
236
|
+
case "vscode":
|
|
237
|
+
case "windsurf":
|
|
238
|
+
case "custom":
|
|
239
|
+
case "cursor":
|
|
240
|
+
return preset
|
|
241
|
+
default:
|
|
242
|
+
return DEFAULT_EDITOR_SETTINGS.preset
|
|
89
243
|
}
|
|
90
|
-
spawnDetached("xdg-open", [resolvedPath])
|
|
91
244
|
}
|
|
@@ -6,6 +6,8 @@ import { TerminalManager } from "./terminal-manager"
|
|
|
6
6
|
|
|
7
7
|
const SHELL_START_TIMEOUT_MS = 5_000
|
|
8
8
|
const COMMAND_TIMEOUT_MS = 5_000
|
|
9
|
+
const FOCUS_IN_SEQUENCE = "\x1b[I"
|
|
10
|
+
const RAW_READ_HEX_COMMAND = `python3 -c "exec('import os,sys,tty,termios,select\\nfd=sys.stdin.fileno()\\nold=termios.tcgetattr(fd)\\ntty.setraw(fd)\\ntry:\\n sys.stdout.write(\"__RAW_READY__\\\\n\")\\n sys.stdout.flush()\\n r,_,_=select.select([fd],[],[],1)\\n data=os.read(fd,8) if r else b\"\"\\n print(data.hex() or \"__EMPTY__\")\\nfinally:\\n termios.tcsetattr(fd, termios.TCSADRAIN, old)')"\r`
|
|
9
11
|
|
|
10
12
|
const isSupportedPlatform = process.platform !== "win32" && typeof Bun.Terminal === "function"
|
|
11
13
|
const describeIfSupported = isSupportedPlatform ? describe : describe.skip
|
|
@@ -58,6 +60,10 @@ async function createSession(terminalId: string) {
|
|
|
58
60
|
}
|
|
59
61
|
}
|
|
60
62
|
|
|
63
|
+
async function waitForOutputToContain(getOutput: () => string, value: string, timeoutMs = COMMAND_TIMEOUT_MS) {
|
|
64
|
+
await waitFor(() => getOutput().includes(value), timeoutMs)
|
|
65
|
+
}
|
|
66
|
+
|
|
61
67
|
describeIfSupported("TerminalManager", () => {
|
|
62
68
|
test("ctrl+c interrupts the foreground job and keeps the shell alive", async () => {
|
|
63
69
|
const terminalId = "terminal-ctrl-c-foreground"
|
|
@@ -112,4 +118,160 @@ describeIfSupported("TerminalManager", () => {
|
|
|
112
118
|
manager.close(terminalId)
|
|
113
119
|
}
|
|
114
120
|
})
|
|
121
|
+
|
|
122
|
+
test("filters leaked focus reports while focus mode is disabled", async () => {
|
|
123
|
+
const terminalId = "terminal-focus-filtered"
|
|
124
|
+
const { manager, getOutput } = await createSession(terminalId)
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const beforeLength = getOutput().length
|
|
128
|
+
manager.write(terminalId, RAW_READ_HEX_COMMAND)
|
|
129
|
+
await waitForOutputToContain(getOutput, "__RAW_READY__")
|
|
130
|
+
|
|
131
|
+
manager.write(terminalId, FOCUS_IN_SEQUENCE)
|
|
132
|
+
await waitForOutputToContain(getOutput, "__EMPTY__")
|
|
133
|
+
|
|
134
|
+
const interactionOutput = getOutput().slice(beforeLength)
|
|
135
|
+
expect(interactionOutput).toContain("__EMPTY__")
|
|
136
|
+
expect(interactionOutput).not.toContain("1b5b49")
|
|
137
|
+
} finally {
|
|
138
|
+
manager.close(terminalId)
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test("forwards focus reports when the session mode is enabled", () => {
|
|
143
|
+
const manager = new TerminalManager() as unknown as {
|
|
144
|
+
sessions: Map<
|
|
145
|
+
string,
|
|
146
|
+
{
|
|
147
|
+
status: "running" | "exited"
|
|
148
|
+
focusReportingEnabled: boolean
|
|
149
|
+
terminal: { write: (data: string) => void }
|
|
150
|
+
process: Bun.Subprocess | null
|
|
151
|
+
}
|
|
152
|
+
>
|
|
153
|
+
write: (terminalId: string, data: string) => void
|
|
154
|
+
}
|
|
155
|
+
const writes: string[] = []
|
|
156
|
+
|
|
157
|
+
manager.sessions.set("terminal-focus-forwarded", {
|
|
158
|
+
status: "running",
|
|
159
|
+
focusReportingEnabled: true,
|
|
160
|
+
terminal: {
|
|
161
|
+
write(data: string) {
|
|
162
|
+
writes.push(data)
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
process: null,
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
manager.write("terminal-focus-forwarded", FOCUS_IN_SEQUENCE)
|
|
169
|
+
|
|
170
|
+
expect(writes).toEqual([FOCUS_IN_SEQUENCE])
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test("resize signals the shell process group with SIGWINCH", () => {
|
|
174
|
+
const manager = new TerminalManager() as unknown as {
|
|
175
|
+
sessions: Map<
|
|
176
|
+
string,
|
|
177
|
+
{
|
|
178
|
+
cols: number
|
|
179
|
+
rows: number
|
|
180
|
+
headless: { resize: (cols: number, rows: number) => void }
|
|
181
|
+
terminal: { resize: (cols: number, rows: number) => void }
|
|
182
|
+
process: { pid: number } | null
|
|
183
|
+
}
|
|
184
|
+
>
|
|
185
|
+
resize: (terminalId: string, cols: number, rows: number) => void
|
|
186
|
+
}
|
|
187
|
+
const resizeCalls: Array<{ cols: number; rows: number }> = []
|
|
188
|
+
const killCalls: Array<{ pid: number; signal: NodeJS.Signals }> = []
|
|
189
|
+
const originalKill = process.kill
|
|
190
|
+
|
|
191
|
+
;(process as typeof process & {
|
|
192
|
+
kill: (pid: number, signal?: NodeJS.Signals | number) => boolean
|
|
193
|
+
}).kill = ((pid: number, signal?: NodeJS.Signals | number) => {
|
|
194
|
+
if (typeof signal === "string") {
|
|
195
|
+
killCalls.push({ pid, signal })
|
|
196
|
+
}
|
|
197
|
+
return true
|
|
198
|
+
}) as typeof process.kill
|
|
199
|
+
|
|
200
|
+
manager.sessions.set("terminal-resize-sigwinch", {
|
|
201
|
+
cols: 80,
|
|
202
|
+
rows: 24,
|
|
203
|
+
headless: {
|
|
204
|
+
resize(cols, rows) {
|
|
205
|
+
resizeCalls.push({ cols, rows })
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
terminal: {
|
|
209
|
+
resize(cols, rows) {
|
|
210
|
+
resizeCalls.push({ cols, rows })
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
process: { pid: 4321 },
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
try {
|
|
217
|
+
manager.resize("terminal-resize-sigwinch", 120, 40)
|
|
218
|
+
} finally {
|
|
219
|
+
process.kill = originalKill
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
expect(resizeCalls).toEqual([
|
|
223
|
+
{ cols: 120, rows: 40 },
|
|
224
|
+
{ cols: 120, rows: 40 },
|
|
225
|
+
])
|
|
226
|
+
expect(killCalls).toContainEqual({ pid: -4321, signal: "SIGWINCH" })
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test("new sessions reset focus mode back to filtered", async () => {
|
|
230
|
+
const manager = new TerminalManager()
|
|
231
|
+
const firstTerminalId = "terminal-focus-first"
|
|
232
|
+
const secondTerminalId = "terminal-focus-second"
|
|
233
|
+
let outputByTerminalId = new Map<string, string>()
|
|
234
|
+
|
|
235
|
+
manager.onEvent((event) => {
|
|
236
|
+
if (event.type !== "terminal.output") return
|
|
237
|
+
outputByTerminalId.set(event.terminalId, `${outputByTerminalId.get(event.terminalId) ?? ""}${event.data}`)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
const getOutput = (terminalId: string) => outputByTerminalId.get(terminalId) ?? ""
|
|
241
|
+
|
|
242
|
+
const createManagedSession = async (terminalId: string) => {
|
|
243
|
+
manager.createTerminal({
|
|
244
|
+
projectPath: tempProjectPath,
|
|
245
|
+
terminalId,
|
|
246
|
+
cols: 80,
|
|
247
|
+
rows: 24,
|
|
248
|
+
scrollback: 1_000,
|
|
249
|
+
})
|
|
250
|
+
manager.write(terminalId, "printf '__KANNA_READY__\\n'\r")
|
|
251
|
+
await waitForOutputToContain(() => getOutput(terminalId), "__KANNA_READY__", SHELL_START_TIMEOUT_MS)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
await createManagedSession(firstTerminalId)
|
|
256
|
+
const firstBeforeLength = getOutput(firstTerminalId).length
|
|
257
|
+
manager.write(firstTerminalId, "printf '\\033[?1004h'\r")
|
|
258
|
+
await waitFor(() => getOutput(firstTerminalId).length > firstBeforeLength, COMMAND_TIMEOUT_MS)
|
|
259
|
+
manager.close(firstTerminalId)
|
|
260
|
+
|
|
261
|
+
await createManagedSession(secondTerminalId)
|
|
262
|
+
const before = getOutput(secondTerminalId).length
|
|
263
|
+
manager.write(secondTerminalId, "cat -v\r")
|
|
264
|
+
await waitFor(() => getOutput(secondTerminalId).length > before, COMMAND_TIMEOUT_MS)
|
|
265
|
+
manager.write(secondTerminalId, FOCUS_IN_SEQUENCE)
|
|
266
|
+
manager.write(secondTerminalId, "\x03")
|
|
267
|
+
manager.write(secondTerminalId, "printf '__KANNA_FRESH_SESSION__\\n'\r")
|
|
268
|
+
await waitForOutputToContain(() => getOutput(secondTerminalId), "__KANNA_FRESH_SESSION__")
|
|
269
|
+
|
|
270
|
+
const interactionOutput = getOutput(secondTerminalId).slice(before)
|
|
271
|
+
expect(interactionOutput).not.toContain("^[[I")
|
|
272
|
+
} finally {
|
|
273
|
+
manager.close(firstTerminalId)
|
|
274
|
+
manager.close(secondTerminalId)
|
|
275
|
+
}
|
|
276
|
+
})
|
|
115
277
|
})
|
|
@@ -10,6 +10,9 @@ const DEFAULT_ROWS = 24
|
|
|
10
10
|
const DEFAULT_SCROLLBACK = 1_000
|
|
11
11
|
const MIN_SCROLLBACK = 500
|
|
12
12
|
const MAX_SCROLLBACK = 5_000
|
|
13
|
+
const FOCUS_IN_SEQUENCE = "\x1b[I"
|
|
14
|
+
const FOCUS_OUT_SEQUENCE = "\x1b[O"
|
|
15
|
+
const MODE_SEQUENCE_TAIL_LENGTH = 16
|
|
13
16
|
|
|
14
17
|
interface CreateTerminalArgs {
|
|
15
18
|
projectPath: string
|
|
@@ -33,6 +36,8 @@ interface TerminalSession {
|
|
|
33
36
|
terminal: Bun.Terminal
|
|
34
37
|
headless: Terminal
|
|
35
38
|
serializeAddon: SerializeAddon
|
|
39
|
+
focusReportingEnabled: boolean
|
|
40
|
+
modeSequenceTail: string
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
function clampScrollback(value: number) {
|
|
@@ -78,6 +83,25 @@ function createTerminalEnv() {
|
|
|
78
83
|
}
|
|
79
84
|
}
|
|
80
85
|
|
|
86
|
+
function updateFocusReportingState(session: Pick<TerminalSession, "focusReportingEnabled" | "modeSequenceTail">, chunk: string) {
|
|
87
|
+
const combined = session.modeSequenceTail + chunk
|
|
88
|
+
const regex = /\x1b\[\?1004([hl])/g
|
|
89
|
+
|
|
90
|
+
for (const match of combined.matchAll(regex)) {
|
|
91
|
+
session.focusReportingEnabled = match[1] === "h"
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
session.modeSequenceTail = combined.slice(-MODE_SEQUENCE_TAIL_LENGTH)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function filterFocusReportInput(data: string, allowFocusReporting: boolean) {
|
|
98
|
+
if (allowFocusReporting || (!data.includes(FOCUS_IN_SEQUENCE) && !data.includes(FOCUS_OUT_SEQUENCE))) {
|
|
99
|
+
return data
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return data.replaceAll(FOCUS_IN_SEQUENCE, "").replaceAll(FOCUS_OUT_SEQUENCE, "")
|
|
103
|
+
}
|
|
104
|
+
|
|
81
105
|
function killTerminalProcessTree(subprocess: Bun.Subprocess | null) {
|
|
82
106
|
if (!subprocess) return
|
|
83
107
|
|
|
@@ -150,6 +174,7 @@ export class TerminalManager {
|
|
|
150
174
|
existing.headless.options.scrollback = existing.scrollback
|
|
151
175
|
existing.headless.resize(existing.cols, existing.rows)
|
|
152
176
|
existing.terminal.resize(existing.cols, existing.rows)
|
|
177
|
+
signalTerminalProcessGroup(existing.process, "SIGWINCH")
|
|
153
178
|
return this.snapshotOf(existing)
|
|
154
179
|
}
|
|
155
180
|
|
|
@@ -179,6 +204,7 @@ export class TerminalManager {
|
|
|
179
204
|
name: "xterm-256color",
|
|
180
205
|
data: (_terminal, data) => {
|
|
181
206
|
const chunk = Buffer.from(data).toString("utf8")
|
|
207
|
+
updateFocusReportingState(session, chunk)
|
|
182
208
|
headless.write(chunk)
|
|
183
209
|
this.emit({
|
|
184
210
|
type: "terminal.output",
|
|
@@ -189,6 +215,8 @@ export class TerminalManager {
|
|
|
189
215
|
}),
|
|
190
216
|
headless,
|
|
191
217
|
serializeAddon,
|
|
218
|
+
focusReportingEnabled: false,
|
|
219
|
+
modeSequenceTail: "",
|
|
192
220
|
}
|
|
193
221
|
|
|
194
222
|
try {
|
|
@@ -203,7 +231,6 @@ export class TerminalManager {
|
|
|
203
231
|
session.headless.dispose()
|
|
204
232
|
throw error
|
|
205
233
|
}
|
|
206
|
-
|
|
207
234
|
void session.process.exited.then((exitCode) => {
|
|
208
235
|
const active = this.sessions.get(args.terminalId)
|
|
209
236
|
if (!active) return
|
|
@@ -244,18 +271,21 @@ export class TerminalManager {
|
|
|
244
271
|
const session = this.sessions.get(terminalId)
|
|
245
272
|
if (!session || session.status === "exited") return
|
|
246
273
|
|
|
274
|
+
const filteredData = filterFocusReportInput(data, session.focusReportingEnabled)
|
|
275
|
+
if (!filteredData) return
|
|
276
|
+
|
|
247
277
|
let cursor = 0
|
|
248
278
|
|
|
249
|
-
while (cursor <
|
|
250
|
-
const ctrlCIndex =
|
|
279
|
+
while (cursor < filteredData.length) {
|
|
280
|
+
const ctrlCIndex = filteredData.indexOf("\x03", cursor)
|
|
251
281
|
|
|
252
282
|
if (ctrlCIndex === -1) {
|
|
253
|
-
session.terminal.write(
|
|
283
|
+
session.terminal.write(filteredData.slice(cursor))
|
|
254
284
|
return
|
|
255
285
|
}
|
|
256
286
|
|
|
257
287
|
if (ctrlCIndex > cursor) {
|
|
258
|
-
session.terminal.write(
|
|
288
|
+
session.terminal.write(filteredData.slice(cursor, ctrlCIndex))
|
|
259
289
|
}
|
|
260
290
|
|
|
261
291
|
signalTerminalProcessGroup(session.process, "SIGINT")
|
|
@@ -270,6 +300,7 @@ export class TerminalManager {
|
|
|
270
300
|
session.rows = normalizeTerminalDimension(rows, session.rows)
|
|
271
301
|
session.headless.resize(session.cols, session.rows)
|
|
272
302
|
session.terminal.resize(session.cols, session.rows)
|
|
303
|
+
signalTerminalProcessGroup(session.process, "SIGWINCH")
|
|
273
304
|
}
|
|
274
305
|
|
|
275
306
|
close(terminalId: string) {
|
package/src/server/ws-router.ts
CHANGED