kanna-code 0.4.2 → 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.
@@ -0,0 +1,319 @@
1
+ import path from "node:path"
2
+ import process from "node:process"
3
+ import defaultShell, { detectDefaultShell } from "default-shell"
4
+ import { Terminal } from "@xterm/headless"
5
+ import { SerializeAddon } from "@xterm/addon-serialize"
6
+ import type { TerminalEvent, TerminalSnapshot } from "../shared/protocol"
7
+
8
+ const DEFAULT_COLS = 80
9
+ const DEFAULT_ROWS = 24
10
+ const DEFAULT_SCROLLBACK = 1_000
11
+ const MIN_SCROLLBACK = 500
12
+ const MAX_SCROLLBACK = 5_000
13
+
14
+ interface CreateTerminalArgs {
15
+ projectPath: string
16
+ terminalId: string
17
+ cols: number
18
+ rows: number
19
+ scrollback: number
20
+ }
21
+
22
+ interface TerminalSession {
23
+ terminalId: string
24
+ title: string
25
+ cwd: string
26
+ shell: string
27
+ cols: number
28
+ rows: number
29
+ scrollback: number
30
+ status: "running" | "exited"
31
+ exitCode: number | null
32
+ process: Bun.Subprocess | null
33
+ terminal: Bun.Terminal
34
+ headless: Terminal
35
+ serializeAddon: SerializeAddon
36
+ }
37
+
38
+ function clampScrollback(value: number) {
39
+ if (!Number.isFinite(value)) return DEFAULT_SCROLLBACK
40
+ return Math.min(MAX_SCROLLBACK, Math.max(MIN_SCROLLBACK, Math.round(value)))
41
+ }
42
+
43
+ function normalizeTerminalDimension(value: number, fallback: number) {
44
+ if (!Number.isFinite(value)) return fallback
45
+ return Math.max(1, Math.round(value))
46
+ }
47
+
48
+ function resolveShell() {
49
+ try {
50
+ return detectDefaultShell()
51
+ } catch {
52
+ if (defaultShell) return defaultShell
53
+ if (process.platform === "win32") {
54
+ return process.env.ComSpec || "cmd.exe"
55
+ }
56
+ return process.env.SHELL || "/bin/sh"
57
+ }
58
+ }
59
+
60
+ function resolveShellArgs(shellPath: string) {
61
+ if (process.platform === "win32") {
62
+ return []
63
+ }
64
+
65
+ const shellName = path.basename(shellPath)
66
+ if (["bash", "zsh", "fish", "sh", "ksh"].includes(shellName)) {
67
+ return ["-l"]
68
+ }
69
+
70
+ return []
71
+ }
72
+
73
+ function createTerminalEnv() {
74
+ return {
75
+ ...process.env,
76
+ TERM: "xterm-256color",
77
+ COLORTERM: "truecolor",
78
+ }
79
+ }
80
+
81
+ function killTerminalProcessTree(subprocess: Bun.Subprocess | null) {
82
+ if (!subprocess) return
83
+
84
+ const pid = subprocess.pid
85
+ if (typeof pid !== "number") return
86
+
87
+ if (process.platform !== "win32") {
88
+ try {
89
+ process.kill(-pid, "SIGKILL")
90
+ return
91
+ } catch {
92
+ // Fall back to killing only the shell process if group termination fails.
93
+ }
94
+ }
95
+
96
+ try {
97
+ subprocess.kill("SIGKILL")
98
+ } catch {
99
+ // Ignore subprocess shutdown errors during disposal.
100
+ }
101
+ }
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
+
126
+ export class TerminalManager {
127
+ private readonly sessions = new Map<string, TerminalSession>()
128
+ private readonly listeners = new Set<(event: TerminalEvent) => void>()
129
+
130
+ onEvent(listener: (event: TerminalEvent) => void) {
131
+ this.listeners.add(listener)
132
+ return () => {
133
+ this.listeners.delete(listener)
134
+ }
135
+ }
136
+
137
+ createTerminal(args: CreateTerminalArgs) {
138
+ if (process.platform === "win32") {
139
+ throw new Error("Embedded terminal is currently supported on macOS/Linux only.")
140
+ }
141
+ if (typeof Bun.Terminal !== "function") {
142
+ throw new Error("Embedded terminal requires Bun 1.3.5+ with Bun.Terminal support.")
143
+ }
144
+
145
+ const existing = this.sessions.get(args.terminalId)
146
+ if (existing) {
147
+ existing.scrollback = clampScrollback(args.scrollback)
148
+ existing.cols = normalizeTerminalDimension(args.cols, existing.cols)
149
+ existing.rows = normalizeTerminalDimension(args.rows, existing.rows)
150
+ existing.headless.options.scrollback = existing.scrollback
151
+ existing.headless.resize(existing.cols, existing.rows)
152
+ existing.terminal.resize(existing.cols, existing.rows)
153
+ return this.snapshotOf(existing)
154
+ }
155
+
156
+ const shell = resolveShell()
157
+ const cols = normalizeTerminalDimension(args.cols, DEFAULT_COLS)
158
+ const rows = normalizeTerminalDimension(args.rows, DEFAULT_ROWS)
159
+ const scrollback = clampScrollback(args.scrollback)
160
+ const title = path.basename(shell) || "shell"
161
+ const headless = new Terminal({ cols, rows, scrollback, allowProposedApi: true })
162
+ const serializeAddon = new SerializeAddon()
163
+ headless.loadAddon(serializeAddon)
164
+
165
+ const session: TerminalSession = {
166
+ terminalId: args.terminalId,
167
+ title,
168
+ cwd: args.projectPath,
169
+ shell,
170
+ cols,
171
+ rows,
172
+ scrollback,
173
+ status: "running",
174
+ exitCode: null,
175
+ process: null,
176
+ terminal: new Bun.Terminal({
177
+ cols,
178
+ rows,
179
+ name: "xterm-256color",
180
+ data: (_terminal, data) => {
181
+ const chunk = Buffer.from(data).toString("utf8")
182
+ headless.write(chunk)
183
+ this.emit({
184
+ type: "terminal.output",
185
+ terminalId: args.terminalId,
186
+ data: chunk,
187
+ })
188
+ },
189
+ }),
190
+ headless,
191
+ serializeAddon,
192
+ }
193
+
194
+ try {
195
+ session.process = Bun.spawn([shell, ...resolveShellArgs(shell)], {
196
+ cwd: args.projectPath,
197
+ env: createTerminalEnv(),
198
+ terminal: session.terminal,
199
+ })
200
+ } catch (error) {
201
+ session.terminal.close()
202
+ session.serializeAddon.dispose()
203
+ session.headless.dispose()
204
+ throw error
205
+ }
206
+
207
+ void session.process.exited.then((exitCode) => {
208
+ const active = this.sessions.get(args.terminalId)
209
+ if (!active) return
210
+ active.status = "exited"
211
+ active.exitCode = exitCode
212
+ this.emit({
213
+ type: "terminal.exit",
214
+ terminalId: args.terminalId,
215
+ exitCode,
216
+ })
217
+ }).catch((error) => {
218
+ const active = this.sessions.get(args.terminalId)
219
+ if (!active) return
220
+ active.status = "exited"
221
+ active.exitCode = 1
222
+ this.emit({
223
+ type: "terminal.output",
224
+ terminalId: args.terminalId,
225
+ data: `\r\n[terminal error] ${error instanceof Error ? error.message : String(error)}\r\n`,
226
+ })
227
+ this.emit({
228
+ type: "terminal.exit",
229
+ terminalId: args.terminalId,
230
+ exitCode: 1,
231
+ })
232
+ })
233
+
234
+ this.sessions.set(args.terminalId, session)
235
+ return this.snapshotOf(session)
236
+ }
237
+
238
+ getSnapshot(terminalId: string): TerminalSnapshot | null {
239
+ const session = this.sessions.get(terminalId)
240
+ return session ? this.snapshotOf(session) : null
241
+ }
242
+
243
+ write(terminalId: string, data: string) {
244
+ const session = this.sessions.get(terminalId)
245
+ if (!session || session.status === "exited") return
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
+ }
264
+ }
265
+
266
+ resize(terminalId: string, cols: number, rows: number) {
267
+ const session = this.sessions.get(terminalId)
268
+ if (!session) return
269
+ session.cols = normalizeTerminalDimension(cols, session.cols)
270
+ session.rows = normalizeTerminalDimension(rows, session.rows)
271
+ session.headless.resize(session.cols, session.rows)
272
+ session.terminal.resize(session.cols, session.rows)
273
+ }
274
+
275
+ close(terminalId: string) {
276
+ const session = this.sessions.get(terminalId)
277
+ if (!session) return
278
+
279
+ this.sessions.delete(terminalId)
280
+ killTerminalProcessTree(session.process)
281
+ session.terminal.close()
282
+ session.serializeAddon.dispose()
283
+ session.headless.dispose()
284
+ }
285
+
286
+ closeByCwd(cwd: string) {
287
+ for (const [terminalId, session] of this.sessions.entries()) {
288
+ if (session.cwd !== cwd) continue
289
+ this.close(terminalId)
290
+ }
291
+ }
292
+
293
+ closeAll() {
294
+ for (const terminalId of this.sessions.keys()) {
295
+ this.close(terminalId)
296
+ }
297
+ }
298
+
299
+ private snapshotOf(session: TerminalSession): TerminalSnapshot {
300
+ return {
301
+ terminalId: session.terminalId,
302
+ title: session.title,
303
+ cwd: session.cwd,
304
+ shell: session.shell,
305
+ cols: session.cols,
306
+ rows: session.rows,
307
+ scrollback: session.scrollback,
308
+ serializedState: session.serializeAddon.serialize({ scrollback: session.scrollback }),
309
+ status: session.status,
310
+ exitCode: session.exitCode,
311
+ }
312
+ }
313
+
314
+ private emit(event: TerminalEvent) {
315
+ for (const listener of this.listeners) {
316
+ listener(event)
317
+ }
318
+ }
319
+ }
@@ -19,6 +19,10 @@ describe("ws-router", () => {
19
19
  const router = createWsRouter({
20
20
  store: { state: createEmptyState() } as never,
21
21
  agent: { getActiveStatuses: () => new Map() } as never,
22
+ terminals: {
23
+ getSnapshot: () => null,
24
+ onEvent: () => () => {},
25
+ } as never,
22
26
  refreshDiscovery: async () => [],
23
27
  getDiscoveredProjects: () => [],
24
28
  machineDisplayName: "Local Machine",
@@ -44,4 +48,43 @@ describe("ws-router", () => {
44
48
  },
45
49
  ])
46
50
  })
51
+
52
+ test("acks terminal.input without rebroadcasting terminal snapshots", () => {
53
+ const router = createWsRouter({
54
+ store: { state: createEmptyState() } as never,
55
+ agent: { getActiveStatuses: () => new Map() } as never,
56
+ terminals: {
57
+ getSnapshot: () => null,
58
+ onEvent: () => () => {},
59
+ write: () => {},
60
+ } as never,
61
+ refreshDiscovery: async () => [],
62
+ getDiscoveredProjects: () => [],
63
+ machineDisplayName: "Local Machine",
64
+ })
65
+ const ws = new FakeWebSocket()
66
+
67
+ ws.data.subscriptions.set("sub-terminal", { type: "terminal", terminalId: "terminal-1" })
68
+ router.handleMessage(
69
+ ws as never,
70
+ JSON.stringify({
71
+ v: 1,
72
+ type: "command",
73
+ id: "terminal-input-1",
74
+ command: {
75
+ type: "terminal.input",
76
+ terminalId: "terminal-1",
77
+ data: "ls\r",
78
+ },
79
+ })
80
+ )
81
+
82
+ expect(ws.sent).toEqual([
83
+ {
84
+ v: PROTOCOL_VERSION,
85
+ type: "ack",
86
+ id: "terminal-input-1",
87
+ },
88
+ ])
89
+ })
47
90
  })
@@ -7,6 +7,7 @@ import type { DiscoveredProject } from "./discovery"
7
7
  import { EventStore } from "./event-store"
8
8
  import { openExternal } from "./external-open"
9
9
  import { ensureProjectDirectory } from "./paths"
10
+ import { TerminalManager } from "./terminal-manager"
10
11
  import { deriveChatSnapshot, deriveLocalProjectsSnapshot, deriveSidebarData } from "./read-models"
11
12
 
12
13
  export interface ClientState {
@@ -16,6 +17,7 @@ export interface ClientState {
16
17
  interface CreateWsRouterArgs {
17
18
  store: EventStore
18
19
  agent: AgentCoordinator
20
+ terminals: TerminalManager
19
21
  refreshDiscovery: () => Promise<DiscoveredProject[]>
20
22
  getDiscoveredProjects: () => DiscoveredProject[]
21
23
  machineDisplayName: string
@@ -28,6 +30,7 @@ function send(ws: ServerWebSocket<ClientState>, message: ServerEnvelope) {
28
30
  export function createWsRouter({
29
31
  store,
30
32
  agent,
33
+ terminals,
31
34
  refreshDiscovery,
32
35
  getDiscoveredProjects,
33
36
  machineDisplayName,
@@ -62,6 +65,18 @@ export function createWsRouter({
62
65
  }
63
66
  }
64
67
 
68
+ if (topic.type === "terminal") {
69
+ return {
70
+ v: PROTOCOL_VERSION,
71
+ type: "snapshot",
72
+ id,
73
+ snapshot: {
74
+ type: "terminal",
75
+ data: terminals.getSnapshot(topic.terminalId),
76
+ },
77
+ }
78
+ }
79
+
65
80
  return {
66
81
  v: PROTOCOL_VERSION,
67
82
  type: "snapshot",
@@ -85,6 +100,33 @@ export function createWsRouter({
85
100
  }
86
101
  }
87
102
 
103
+ function pushTerminalSnapshot(terminalId: string) {
104
+ for (const ws of sockets) {
105
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
106
+ if (topic.type !== "terminal" || topic.terminalId !== terminalId) continue
107
+ send(ws, createEnvelope(id, topic))
108
+ }
109
+ }
110
+ }
111
+
112
+ function pushTerminalEvent(terminalId: string, event: Extract<ServerEnvelope, { type: "event" }>["event"]) {
113
+ for (const ws of sockets) {
114
+ for (const [id, topic] of ws.data.subscriptions.entries()) {
115
+ if (topic.type !== "terminal" || topic.terminalId !== terminalId) continue
116
+ send(ws, {
117
+ v: PROTOCOL_VERSION,
118
+ type: "event",
119
+ id,
120
+ event,
121
+ })
122
+ }
123
+ }
124
+ }
125
+
126
+ const disposeTerminalEvents = terminals.onEvent((event) => {
127
+ pushTerminalEvent(event.terminalId, event)
128
+ })
129
+
88
130
  async function handleCommand(ws: ServerWebSocket<ClientState>, message: Extract<ClientEnvelope, { type: "command" }>) {
89
131
  const { command, id } = message
90
132
  try {
@@ -108,9 +150,13 @@ export function createWsRouter({
108
150
  break
109
151
  }
110
152
  case "project.remove": {
153
+ const project = store.getProject(command.projectId)
111
154
  for (const chat of store.listChatsByProject(command.projectId)) {
112
155
  await agent.cancel(chat.id)
113
156
  }
157
+ if (project) {
158
+ terminals.closeByCwd(project.localPath)
159
+ }
114
160
  await store.removeProject(command.projectId)
115
161
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
116
162
  break
@@ -151,6 +197,37 @@ export function createWsRouter({
151
197
  send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
152
198
  break
153
199
  }
200
+ case "terminal.create": {
201
+ const project = store.getProject(command.projectId)
202
+ if (!project) {
203
+ throw new Error("Project not found")
204
+ }
205
+ const snapshot = terminals.createTerminal({
206
+ projectPath: project.localPath,
207
+ terminalId: command.terminalId,
208
+ cols: command.cols,
209
+ rows: command.rows,
210
+ scrollback: command.scrollback,
211
+ })
212
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: snapshot })
213
+ return
214
+ }
215
+ case "terminal.input": {
216
+ terminals.write(command.terminalId, command.data)
217
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
218
+ return
219
+ }
220
+ case "terminal.resize": {
221
+ terminals.resize(command.terminalId, command.cols, command.rows)
222
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
223
+ return
224
+ }
225
+ case "terminal.close": {
226
+ terminals.close(command.terminalId)
227
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
228
+ pushTerminalSnapshot(command.terminalId)
229
+ return
230
+ }
154
231
  }
155
232
 
156
233
  broadcastSnapshots()
@@ -203,5 +280,8 @@ export function createWsRouter({
203
280
 
204
281
  void handleCommand(ws, parsed)
205
282
  },
283
+ dispose() {
284
+ disposeTerminalEvents()
285
+ },
206
286
  }
207
287
  }
@@ -4,6 +4,25 @@ export type SubscriptionTopic =
4
4
  | { type: "sidebar" }
5
5
  | { type: "local-projects" }
6
6
  | { type: "chat"; chatId: string }
7
+ | { type: "terminal"; terminalId: string }
8
+
9
+ export interface TerminalSnapshot {
10
+ terminalId: string
11
+ title: string
12
+ cwd: string
13
+ shell: string
14
+ cols: number
15
+ rows: number
16
+ scrollback: number
17
+ serializedState: string
18
+ status: "running" | "exited"
19
+ exitCode: number | null
20
+ signal?: number
21
+ }
22
+
23
+ export type TerminalEvent =
24
+ | { type: "terminal.output"; terminalId: string; data: string }
25
+ | { type: "terminal.exit"; terminalId: string; exitCode: number; signal?: number }
7
26
 
8
27
  export type ClientCommand =
9
28
  | { type: "project.open"; localPath: string }
@@ -27,6 +46,10 @@ export type ClientCommand =
27
46
  }
28
47
  | { type: "chat.cancel"; chatId: string }
29
48
  | { type: "chat.respondTool"; chatId: string; toolUseId: string; result: unknown }
49
+ | { type: "terminal.create"; projectId: string; terminalId: string; cols: number; rows: number; scrollback: number }
50
+ | { type: "terminal.input"; terminalId: string; data: string }
51
+ | { type: "terminal.resize"; terminalId: string; cols: number; rows: number }
52
+ | { type: "terminal.close"; terminalId: string }
30
53
 
31
54
  export type ClientEnvelope =
32
55
  | { v: 1; type: "subscribe"; id: string; topic: SubscriptionTopic }
@@ -37,9 +60,11 @@ export type ServerSnapshot =
37
60
  | { type: "sidebar"; data: SidebarData }
38
61
  | { type: "local-projects"; data: LocalProjectsSnapshot }
39
62
  | { type: "chat"; data: ChatSnapshot | null }
63
+ | { type: "terminal"; data: TerminalSnapshot | null }
40
64
 
41
65
  export type ServerEnvelope =
42
66
  | { v: 1; type: "snapshot"; id: string; snapshot: ServerSnapshot }
67
+ | { v: 1; type: "event"; id: string; event: TerminalEvent }
43
68
  | { v: 1; type: "ack"; id: string; result?: unknown }
44
69
  | { v: 1; type: "error"; id?: string; message: string }
45
70