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.
@@ -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-DPinj1Li.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-C4BaFDD7.css">
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.6.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 OpenExternalAction = Extract<ClientCommand, { type: "system.openExternal" }>["action"]
7
+ type OpenExternalCommand = Extract<ClientCommand, { type: "system.openExternal" }>
7
8
 
8
- export function openExternal(localPath: string, action: OpenExternalAction) {
9
- const resolvedPath = resolveLocalPath(localPath)
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 command of ["x-terminal-emulator", "gnome-terminal", "konsole"]) {
69
- if (!hasCommand(command)) continue
70
- if (command === "gnome-terminal") {
71
- spawnDetached(command, ["--working-directory", resolvedPath])
72
- } else if (command === "konsole") {
73
- spawnDetached(command, ["--workdir", resolvedPath])
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(command, ["--working-directory", resolvedPath])
78
+ spawnDetached(terminalCommand, ["--working-directory", resolvedPath])
76
79
  }
77
80
  return
78
81
  }
79
82
  spawnDetached("xdg-open", [resolvedPath])
80
- return
81
83
  }
82
- if (hasCommand("cursor")) {
83
- spawnDetached("cursor", [resolvedPath])
84
- return
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
- if (hasCommand("code")) {
87
- spawnDetached("code", [resolvedPath])
88
- return
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 < data.length) {
250
- const ctrlCIndex = data.indexOf("\x03", cursor)
279
+ while (cursor < filteredData.length) {
280
+ const ctrlCIndex = filteredData.indexOf("\x03", cursor)
251
281
 
252
282
  if (ctrlCIndex === -1) {
253
- session.terminal.write(data.slice(cursor))
283
+ session.terminal.write(filteredData.slice(cursor))
254
284
  return
255
285
  }
256
286
 
257
287
  if (ctrlCIndex > cursor) {
258
- session.terminal.write(data.slice(cursor, ctrlCIndex))
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) {
@@ -162,7 +162,7 @@ export function createWsRouter({
162
162
  break
163
163
  }
164
164
  case "system.openExternal": {
165
- openExternal(command.localPath, command.action)
165
+ await openExternal(command)
166
166
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
167
167
  break
168
168
  }