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.
- package/README.md +4 -2
- package/dist/client/assets/index-DKU6KOsn.js +478 -0
- package/dist/client/assets/index-DzxKYydf.css +32 -0
- package/dist/client/index.html +2 -2
- package/package.json +9 -3
- package/src/server/cli-runtime.test.ts +13 -0
- package/src/server/cli-runtime.ts +8 -0
- package/src/server/cli.ts +1 -0
- package/src/server/read-models.ts +1 -1
- package/src/server/server.ts +5 -0
- package/src/server/terminal-manager.test.ts +115 -0
- package/src/server/terminal-manager.ts +319 -0
- package/src/server/ws-router.test.ts +43 -0
- package/src/server/ws-router.ts +80 -0
- package/src/shared/protocol.ts +25 -0
- package/dist/client/assets/index--aEO6fwe.css +0 -1
- package/dist/client/assets/index-dwpQ0Tai.js +0 -419
|
@@ -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
|
})
|
package/src/server/ws-router.ts
CHANGED
|
@@ -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
|
}
|
package/src/shared/protocol.ts
CHANGED
|
@@ -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
|
|