kanna-code 0.6.1 → 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-CAUb9qP0.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.1",
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
  }
@@ -139,28 +139,6 @@ describeIfSupported("TerminalManager", () => {
139
139
  }
140
140
  })
141
141
 
142
- test("tracks focus report mode after the PTY enables it", async () => {
143
- const terminalId = "terminal-focus-enabled"
144
- const { manager, getOutput } = await createSession(terminalId)
145
-
146
- try {
147
- const enableBeforeLength = getOutput().length
148
- manager.write(terminalId, "printf '\\033[?1004h'\r")
149
- await waitFor(() => getOutput().length > enableBeforeLength, COMMAND_TIMEOUT_MS)
150
- await waitFor(
151
- () =>
152
- (
153
- (manager as unknown as {
154
- sessions: Map<string, { focusReportingEnabled: boolean }>
155
- }).sessions.get(terminalId)?.focusReportingEnabled
156
- ) === true,
157
- COMMAND_TIMEOUT_MS
158
- )
159
- } finally {
160
- manager.close(terminalId)
161
- }
162
- })
163
-
164
142
  test("forwards focus reports when the session mode is enabled", () => {
165
143
  const manager = new TerminalManager() as unknown as {
166
144
  sessions: Map<
@@ -192,6 +170,62 @@ describeIfSupported("TerminalManager", () => {
192
170
  expect(writes).toEqual([FOCUS_IN_SEQUENCE])
193
171
  })
194
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
+
195
229
  test("new sessions reset focus mode back to filtered", async () => {
196
230
  const manager = new TerminalManager()
197
231
  const firstTerminalId = "terminal-focus-first"
@@ -174,6 +174,7 @@ export class TerminalManager {
174
174
  existing.headless.options.scrollback = existing.scrollback
175
175
  existing.headless.resize(existing.cols, existing.rows)
176
176
  existing.terminal.resize(existing.cols, existing.rows)
177
+ signalTerminalProcessGroup(existing.process, "SIGWINCH")
177
178
  return this.snapshotOf(existing)
178
179
  }
179
180
 
@@ -299,6 +300,7 @@ export class TerminalManager {
299
300
  session.rows = normalizeTerminalDimension(rows, session.rows)
300
301
  session.headless.resize(session.cols, session.rows)
301
302
  session.terminal.resize(session.cols, session.rows)
303
+ signalTerminalProcessGroup(session.process, "SIGWINCH")
302
304
  }
303
305
 
304
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
  }
@@ -1,5 +1,12 @@
1
1
  import type { AgentProvider, ChatSnapshot, LocalProjectsSnapshot, ModelOptions, SidebarData } from "./types"
2
2
 
3
+ export type EditorPreset = "cursor" | "vscode" | "windsurf" | "custom"
4
+
5
+ export interface EditorOpenSettings {
6
+ preset: EditorPreset
7
+ commandTemplate: string
8
+ }
9
+
3
10
  export type SubscriptionTopic =
4
11
  | { type: "sidebar" }
5
12
  | { type: "local-projects" }
@@ -29,7 +36,14 @@ export type ClientCommand =
29
36
  | { type: "project.create"; localPath: string; title: string }
30
37
  | { type: "project.remove"; projectId: string }
31
38
  | { type: "system.ping" }
32
- | { type: "system.openExternal"; localPath: string; action: "open_finder" | "open_terminal" | "open_editor" }
39
+ | {
40
+ type: "system.openExternal"
41
+ localPath: string
42
+ action: "open_finder" | "open_terminal" | "open_editor"
43
+ line?: number
44
+ column?: number
45
+ editor?: EditorOpenSettings
46
+ }
33
47
  | { type: "chat.create"; projectId: string }
34
48
  | { type: "chat.rename"; chatId: string; title: string }
35
49
  | { type: "chat.delete"; chatId: string }