@opencode-cockpit/daemon 0.1.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.
@@ -0,0 +1,8 @@
1
+ const ALPHABET = "abcdefghijklmnopqrstuvwxyz234567"
2
+
3
+ export function newShellId(): string {
4
+ const bytes = crypto.getRandomValues(new Uint8Array(8))
5
+ let id = "sh_"
6
+ for (const byte of bytes) id += ALPHABET[byte % 32]
7
+ return id
8
+ }
@@ -0,0 +1,323 @@
1
+ import type { ShellInfo, StartParams } from "@opencode-cockpit/protocol/shell"
2
+ import { invalidParams, invalidState, notFound } from "../../core/errors.ts"
3
+ import type { Logger } from "../../core/logger.ts"
4
+ import { silentLogger } from "../../core/logger.ts"
5
+ import type { MethodTable, Module, ModuleContext, Peer } from "../../core/module.ts"
6
+ import { newShellId } from "./ids.ts"
7
+ import { bunPtyBackend, type PtyBackend } from "./pty.ts"
8
+ import { ProcessRegistry } from "./registry.ts"
9
+ import { Shell, type ShellLimits } from "./shell.ts"
10
+ import { waitFor } from "./wait.ts"
11
+
12
+ export interface ShellModuleOptions {
13
+ backend?: PtyBackend
14
+ limits?: Partial<ShellLimits>
15
+ /** Oldest finished shells are forgotten beyond this many. */
16
+ maxFinished?: number
17
+ /** Base environment for spawned processes (defaults to the daemon's). */
18
+ baseEnv?: Record<string, string | undefined>
19
+ /** Coalesce output events per attached peer for this long. */
20
+ outputFlushMs?: number
21
+ /** Where to record owned process groups so a restarted daemon can reap orphans. */
22
+ registryFile?: string
23
+ }
24
+
25
+ const DEFAULT_LIMITS: ShellLimits = { logChars: 4_000_000, rawBytes: 1_000_000, scrollback: 2000 }
26
+
27
+ export class ShellModule implements Module<"shell"> {
28
+ readonly name = "shell" as const
29
+ private readonly shells = new Map<string, Shell>()
30
+ private readonly attachments = new Map<string, () => void>() // `${peer.id}:${shellId}` → detach
31
+ private readonly backend: PtyBackend
32
+ private readonly limits: ShellLimits
33
+ private log: Logger = silentLogger
34
+ private registry: ProcessRegistry | undefined
35
+ private emit: ModuleContext["emit"] = () => {}
36
+
37
+ constructor(private readonly options: ShellModuleOptions = {}) {
38
+ this.backend = options.backend ?? bunPtyBackend
39
+ this.limits = { ...DEFAULT_LIMITS, ...options.limits }
40
+ }
41
+
42
+ async start(ctx: ModuleContext): Promise<void> {
43
+ this.log = ctx.log
44
+ this.emit = ctx.emit
45
+ if (this.options.registryFile) {
46
+ this.registry = new ProcessRegistry(this.options.registryFile, ctx.log)
47
+ const reaped = this.registry.reap()
48
+ if (reaped > 0) ctx.log.warn("cleaned up shells left by a previous daemon", { reaped })
49
+ }
50
+ }
51
+
52
+ async stop(): Promise<void> {
53
+ await Promise.all([...this.shells.values()].map((s) => s.stop("SIGTERM", 2000).catch(() => {})))
54
+ for (const detach of this.attachments.values()) detach()
55
+ for (const shell of this.shells.values()) shell.dispose()
56
+ this.attachments.clear()
57
+ this.shells.clear()
58
+ }
59
+
60
+ busy(): boolean {
61
+ for (const shell of this.shells.values()) if (shell.running) return true
62
+ return false
63
+ }
64
+
65
+ readonly methods: MethodTable<"shell"> = {
66
+ start: (params) => this.startShell(params),
67
+
68
+ list: (params) => {
69
+ const owner = params.owner
70
+ return [...this.shells.values()]
71
+ .filter((s) => params.includeExited || s.running)
72
+ .filter((s) => !owner?.project || s.spec.owner.project === owner.project)
73
+ .filter((s) => !owner?.session || s.spec.owner.session === owner.session)
74
+ .map((s) => s.info())
75
+ },
76
+
77
+ get: ({ id }) => this.require(id).info(),
78
+
79
+ read: ({ id, after, tail, limit, grep, ignoreCase }) => {
80
+ const shell = this.require(id)
81
+ const page = shell.log.read({
82
+ after,
83
+ tail,
84
+ limit,
85
+ grep: grep === undefined ? undefined : compilePattern(grep, ignoreCase),
86
+ })
87
+ return { ...page, status: shell.status }
88
+ },
89
+
90
+ screen: ({ id }) => this.require(id).snapshot(),
91
+
92
+ write: ({ id, data }) => {
93
+ const shell = this.require(id)
94
+ if (!shell.running) throw invalidState(`shell ${id} is ${shell.status}`)
95
+ return { bytes: shell.write(data) }
96
+ },
97
+
98
+ resize: ({ id, cols, rows }) => {
99
+ this.require(id).resize(cols, rows)
100
+ return {}
101
+ },
102
+
103
+ wait: async (params) => {
104
+ const shell = this.require(params.id)
105
+ const outcome = await waitFor(shell, params, compilePattern)
106
+ return { ...outcome, info: shell.info() }
107
+ },
108
+
109
+ stop: async ({ id, signal, graceMs }) => {
110
+ const shell = this.require(id)
111
+ await shell.stop(signal, graceMs)
112
+ await shell.exited
113
+ return shell.info()
114
+ },
115
+
116
+ restart: async ({ id }) => {
117
+ const shell = this.require(id)
118
+ if (shell.running) {
119
+ await shell.stop("SIGTERM", 3000)
120
+ await shell.exited
121
+ }
122
+ this.spawn(shell)
123
+ return shell.info()
124
+ },
125
+
126
+ remove: async ({ id }) => {
127
+ const shell = this.require(id)
128
+ if (shell.running) {
129
+ await shell.stop("SIGTERM", 3000)
130
+ await shell.exited
131
+ }
132
+ this.forget(shell)
133
+ return {}
134
+ },
135
+
136
+ attach: ({ id, fromOffset }, { peer }) => {
137
+ const shell = this.require(id)
138
+ this.detach(peer, id)
139
+ const replay = shell.raw.since(fromOffset ?? 0)
140
+ this.attachStream(peer, shell)
141
+ return { offset: replay.offset, replay: Buffer.from(replay.bytes).toString("base64") }
142
+ },
143
+
144
+ clear: ({ owner, finishedBeforeMs }) => {
145
+ const cutoff = Date.now() - (finishedBeforeMs ?? 0)
146
+ const removed: string[] = []
147
+ for (const shell of [...this.shells.values()]) {
148
+ if (shell.running) continue
149
+ const info = shell.info()
150
+ if (owner?.project && info.owner.project !== owner.project) continue
151
+ if (owner?.session && info.owner.session !== owner.session) continue
152
+ if ((info.endedAt ?? 0) > cutoff) continue
153
+ this.forget(shell)
154
+ removed.push(info.id)
155
+ }
156
+ return { removed }
157
+ },
158
+
159
+ detach: ({ id }, { peer }) => {
160
+ this.detach(peer, id)
161
+ return {}
162
+ },
163
+ }
164
+
165
+ private startShell(params: StartParams): ShellInfo {
166
+ if (params.reuse) {
167
+ const previous = this.findReusable(params)
168
+ if (previous) {
169
+ Object.assign(previous.spec, {
170
+ env: this.environment(params.env),
171
+ title: params.title ?? previous.spec.title,
172
+ timeoutMs: params.timeoutMs,
173
+ owner: params.owner,
174
+ })
175
+ this.spawn(previous)
176
+ return previous.info()
177
+ }
178
+ }
179
+ const shell = new Shell(
180
+ {
181
+ id: this.uniqueId(),
182
+ command: params.command,
183
+ args: params.args,
184
+ cwd: params.cwd,
185
+ env: this.environment(params.env),
186
+ title: params.title ?? [params.command, ...params.args].join(" ").slice(0, 200),
187
+ cols: params.cols,
188
+ rows: params.rows,
189
+ owner: params.owner,
190
+ timeoutMs: params.timeoutMs,
191
+ },
192
+ this.backend,
193
+ this.limits,
194
+ )
195
+ this.shells.set(shell.id, shell)
196
+ shell.subscribe({ exit: (info) => this.onExit(info) })
197
+ this.spawn(shell)
198
+ this.pruneFinished()
199
+ return shell.info()
200
+ }
201
+
202
+ private findReusable(params: StartParams): Shell | undefined {
203
+ const args = JSON.stringify(params.args)
204
+ let match: Shell | undefined
205
+ for (const shell of this.shells.values()) {
206
+ const spec = shell.spec
207
+ if (
208
+ !shell.running &&
209
+ spec.command === params.command &&
210
+ JSON.stringify(spec.args) === args &&
211
+ spec.cwd === params.cwd &&
212
+ spec.owner.project === params.owner.project &&
213
+ spec.owner.session === params.owner.session &&
214
+ (!match || shell.info().startedAt > match.info().startedAt)
215
+ ) {
216
+ match = shell
217
+ }
218
+ }
219
+ return match
220
+ }
221
+
222
+ private spawn(shell: Shell): void {
223
+ try {
224
+ shell.start()
225
+ } catch (err) {
226
+ const info = shell.info()
227
+ this.log.warn("spawn failed", { id: shell.id, command: shell.spec.command, err: String(err) })
228
+ this.emit("shell.exited", info)
229
+ return
230
+ }
231
+ const pid = shell.info().pid
232
+ if (pid) this.registry?.add(shell.id, pid, shell.spec.command)
233
+ this.log.info("shell started", { id: shell.id, command: shell.spec.command, pid })
234
+ this.emit("shell.started", shell.info())
235
+ }
236
+
237
+ private onExit(info: ShellInfo): void {
238
+ this.registry?.remove(info.id)
239
+ this.log.info("shell ended", {
240
+ id: info.id,
241
+ status: info.status,
242
+ exitCode: info.exitCode,
243
+ signal: info.signal,
244
+ })
245
+ this.emit("shell.exited", info)
246
+ }
247
+
248
+ private attachStream(peer: Peer, shell: Shell): void {
249
+ const key = `${peer.id}:${shell.id}`
250
+ const flushMs = this.options.outputFlushMs ?? 16
251
+ let pending: Uint8Array[] = []
252
+ let pendingOffset = 0
253
+ let timer: ReturnType<typeof setTimeout> | undefined
254
+ const flush = () => {
255
+ timer = undefined
256
+ if (pending.length === 0) return
257
+ const data = Buffer.concat(pending).toString("base64")
258
+ peer.send("shell.output", { id: shell.id, offset: pendingOffset, data })
259
+ pending = []
260
+ }
261
+ const unsubscribe = shell.subscribe({
262
+ data(offset, chunk) {
263
+ if (pending.length === 0) pendingOffset = offset
264
+ pending.push(chunk)
265
+ timer ??= setTimeout(flush, flushMs)
266
+ },
267
+ })
268
+ const detach = () => {
269
+ clearTimeout(timer)
270
+ flush()
271
+ unsubscribe()
272
+ this.attachments.delete(key)
273
+ }
274
+ this.attachments.set(key, detach)
275
+ peer.onClose(detach)
276
+ }
277
+
278
+ private detach(peer: Peer, id: string): void {
279
+ this.attachments.get(`${peer.id}:${id}`)?.()
280
+ }
281
+
282
+ private forget(shell: Shell): void {
283
+ for (const [key, detach] of this.attachments) if (key.endsWith(`:${shell.id}`)) detach()
284
+ shell.dispose()
285
+ this.shells.delete(shell.id)
286
+ this.emit("shell.removed", { id: shell.id })
287
+ }
288
+
289
+ private pruneFinished(): void {
290
+ const max = this.options.maxFinished ?? 50
291
+ const finished = [...this.shells.values()].filter((s) => !s.running)
292
+ for (const shell of finished.slice(0, Math.max(0, finished.length - max))) this.forget(shell)
293
+ }
294
+
295
+ private require(id: string): Shell {
296
+ const shell = this.shells.get(id)
297
+ if (!shell) throw notFound(`shell ${id}`)
298
+ return shell
299
+ }
300
+
301
+ private uniqueId(): string {
302
+ let id = newShellId()
303
+ while (this.shells.has(id)) id = newShellId()
304
+ return id
305
+ }
306
+
307
+ private environment(extra: Record<string, string> | undefined): Record<string, string> {
308
+ const env: Record<string, string> = {}
309
+ for (const [k, v] of Object.entries(this.options.baseEnv ?? process.env)) if (v !== undefined) env[k] = v
310
+ env.TERM ??= "xterm-256color"
311
+ env.COLORTERM ??= "truecolor"
312
+ Object.assign(env, extra)
313
+ return env
314
+ }
315
+ }
316
+
317
+ export function compilePattern(pattern: string, ignoreCase: boolean): RegExp {
318
+ try {
319
+ return new RegExp(pattern, ignoreCase ? "i" : "")
320
+ } catch (err) {
321
+ throw invalidParams(`invalid pattern: ${err instanceof Error ? err.message : String(err)}`)
322
+ }
323
+ }
@@ -0,0 +1,92 @@
1
+ import type { LogLine } from "@opencode-cockpit/protocol/shell"
2
+
3
+ export interface ReadQuery {
4
+ after?: number
5
+ tail: number
6
+ limit: number
7
+ grep?: RegExp
8
+ }
9
+
10
+ export interface ReadPage {
11
+ lines: LogLine[]
12
+ firstLine: number
13
+ lastLine: number
14
+ nextCursor: number
15
+ truncated: boolean
16
+ hasMore: boolean
17
+ }
18
+
19
+ /**
20
+ * Committed lines with monotonic numbering (1-based) and a character budget.
21
+ * Eviction drops the oldest lines and advances `firstLine`; numbers are never reused, so cursors
22
+ * held by clients stay meaningful after eviction.
23
+ */
24
+ export class LineLog {
25
+ private lines: string[] = []
26
+ private head = 0
27
+ private chars = 0
28
+ private first = 1
29
+
30
+ constructor(private readonly maxChars = 4_000_000) {}
31
+
32
+ /** Number of the oldest retained line (equals `lastLine + 1` when empty). */
33
+ get firstLine(): number {
34
+ return this.first
35
+ }
36
+
37
+ /** Number of the newest line, or `firstLine - 1` when empty. */
38
+ get lastLine(): number {
39
+ return this.first + (this.lines.length - this.head) - 1
40
+ }
41
+
42
+ append(text: string): LogLine {
43
+ this.lines.push(text)
44
+ this.chars += text.length + 1
45
+ const line = { n: this.lastLine, text }
46
+ this.evict()
47
+ return line
48
+ }
49
+
50
+ get(n: number): string | undefined {
51
+ if (n < this.first || n > this.lastLine) return undefined
52
+ return this.lines[this.head + (n - this.first)]
53
+ }
54
+
55
+ read(query: ReadQuery): ReadPage {
56
+ const last = this.lastLine
57
+ const requestedStart = query.after !== undefined ? query.after + 1 : Math.max(1, last - query.tail + 1)
58
+ const start = Math.max(requestedStart, this.first)
59
+ const truncated = requestedStart < this.first && last >= requestedStart
60
+
61
+ const out: LogLine[] = []
62
+ let n = start
63
+ for (; n <= last && out.length < query.limit; n++) {
64
+ const text = this.lines[this.head + (n - this.first)] as string
65
+ if (query.grep && !query.grep.test(text)) continue
66
+ out.push({ n, text })
67
+ }
68
+ const scannedTo = n - 1
69
+ return {
70
+ lines: out,
71
+ firstLine: this.first,
72
+ lastLine: last,
73
+ nextCursor: Math.max(scannedTo, start - 1),
74
+ truncated,
75
+ hasMore: scannedTo < last,
76
+ }
77
+ }
78
+
79
+ private evict(): void {
80
+ while (this.chars > this.maxChars && this.head < this.lines.length - 1) {
81
+ const dropped = this.lines[this.head] as string
82
+ this.chars -= dropped.length + 1
83
+ this.head++
84
+ this.first++
85
+ }
86
+ // Compact occasionally so the backing array does not grow without bound.
87
+ if (this.head > 4096 && this.head * 2 > this.lines.length) {
88
+ this.lines = this.lines.slice(this.head)
89
+ this.head = 0
90
+ }
91
+ }
92
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Streaming terminal-output normalizer (ADR 0003).
3
+ *
4
+ * Turns a PTY byte stream into committed plain-text lines, applying the parts of terminal
5
+ * semantics that matter for a single line: carriage return and backspace overwrite, tabs, erase
6
+ * in line, and horizontal cursor moves. Escape sequences are consumed and dropped. Anything that
7
+ * moves between lines (cursor up, scroll regions) is out of scope; the screen view handles those.
8
+ */
9
+
10
+ const ESC = 0x1b
11
+ const BEL = 0x07
12
+ const TAB_WIDTH = 8
13
+
14
+ enum State {
15
+ Ground,
16
+ Escape,
17
+ EscapeIntermediate,
18
+ Csi,
19
+ Osc,
20
+ OscEscape,
21
+ }
22
+
23
+ export interface NormalizerOptions {
24
+ /** Force a commit when a line grows past this many characters. */
25
+ maxLineLength?: number
26
+ }
27
+
28
+ export class OutputNormalizer {
29
+ private readonly decoder = new TextDecoder()
30
+ private readonly maxLineLength: number
31
+ private state = State.Ground
32
+ private csiParams = ""
33
+ private cells: string[] = []
34
+ private col = 0
35
+
36
+ constructor(
37
+ private readonly commit: (text: string) => void,
38
+ options: NormalizerOptions = {},
39
+ ) {
40
+ this.maxLineLength = options.maxLineLength ?? 10_000
41
+ }
42
+
43
+ /** Text of the line currently being written (not yet terminated by a newline). */
44
+ get partial(): string {
45
+ return this.render()
46
+ }
47
+
48
+ push(chunk: Uint8Array | string): void {
49
+ const text = typeof chunk === "string" ? chunk : this.decoder.decode(chunk, { stream: true })
50
+ for (const char of text) this.step(char)
51
+ }
52
+
53
+ /** Commit the partial line, if any. Call when the stream ends. */
54
+ flush(): void {
55
+ const tail = this.render()
56
+ if (tail.length > 0) this.commit(tail)
57
+ this.cells = []
58
+ this.col = 0
59
+ }
60
+
61
+ private step(char: string): void {
62
+ const code = char.codePointAt(0) ?? 0
63
+ switch (this.state) {
64
+ case State.Ground:
65
+ this.ground(char, code)
66
+ return
67
+ case State.Escape:
68
+ if (code === 0x5b) {
69
+ this.state = State.Csi
70
+ this.csiParams = ""
71
+ } else if (code === 0x5d) {
72
+ this.state = State.Osc
73
+ } else if (code >= 0x20 && code <= 0x2f) {
74
+ this.state = State.EscapeIntermediate
75
+ } else {
76
+ this.state = State.Ground
77
+ }
78
+ return
79
+ case State.EscapeIntermediate:
80
+ if (code >= 0x30 && code <= 0x7e) this.state = State.Ground
81
+ return
82
+ case State.Csi:
83
+ if (code >= 0x40 && code <= 0x7e) {
84
+ this.csi(char, this.csiParams)
85
+ this.state = State.Ground
86
+ } else {
87
+ this.csiParams += char
88
+ }
89
+ return
90
+ case State.Osc:
91
+ if (code === BEL) this.state = State.Ground
92
+ else if (code === ESC) this.state = State.OscEscape
93
+ return
94
+ case State.OscEscape:
95
+ this.state = code === 0x5c ? State.Ground : State.Osc
96
+ return
97
+ }
98
+ }
99
+
100
+ private ground(char: string, code: number): void {
101
+ if (code === ESC) {
102
+ this.state = State.Escape
103
+ return
104
+ }
105
+ if (char === "\n") {
106
+ this.commit(this.render())
107
+ this.cells = []
108
+ this.col = 0
109
+ return
110
+ }
111
+ if (char === "\r") {
112
+ this.col = 0
113
+ return
114
+ }
115
+ if (char === "\b") {
116
+ this.col = Math.max(0, this.col - 1)
117
+ return
118
+ }
119
+ if (char === "\t") {
120
+ const next = (Math.floor(this.col / TAB_WIDTH) + 1) * TAB_WIDTH
121
+ while (this.col < next) this.put(" ")
122
+ return
123
+ }
124
+ if (code < 0x20 || code === 0x7f) return
125
+ this.put(char)
126
+ }
127
+
128
+ private csi(final: string, raw: string): void {
129
+ const params = raw.replace(/^[?>=!]/, "")
130
+ const first = Number.parseInt(params.split(";")[0] ?? "", 10)
131
+ const n = Number.isNaN(first) ? undefined : first
132
+ switch (final) {
133
+ case "K": // erase in line
134
+ if (n === undefined || n === 0) this.cells.length = Math.min(this.cells.length, this.col)
135
+ else if (n === 1) for (let i = 0; i <= this.col && i < this.cells.length; i++) this.cells[i] = " "
136
+ else if (n === 2) this.cells = []
137
+ return
138
+ case "G": // cursor horizontal absolute (1-based)
139
+ this.col = Math.max(0, (n ?? 1) - 1)
140
+ return
141
+ case "C": // cursor forward
142
+ this.col += Math.max(1, n ?? 1)
143
+ return
144
+ case "D": // cursor back
145
+ this.col = Math.max(0, this.col - Math.max(1, n ?? 1))
146
+ return
147
+ case "J": // erase display: the current line is all this view can clear
148
+ if (n === 2 || n === 3) {
149
+ this.cells = []
150
+ this.col = 0
151
+ }
152
+ return
153
+ default:
154
+ return // colours (m) and everything else carry no line text
155
+ }
156
+ }
157
+
158
+ private put(char: string): void {
159
+ while (this.cells.length < this.col) this.cells.push(" ")
160
+ this.cells[this.col] = char
161
+ this.col++
162
+ if (this.cells.length >= this.maxLineLength) {
163
+ this.commit(this.render())
164
+ this.cells = []
165
+ this.col = 0
166
+ }
167
+ }
168
+
169
+ private render(): string {
170
+ return this.cells.join("").trimEnd()
171
+ }
172
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Bounded store of raw PTY bytes addressed by absolute offset, for replaying output to UIs that
3
+ * attach after the fact. Evicts whole chunks from the front.
4
+ */
5
+ export class RawRing {
6
+ private chunks: Uint8Array[] = []
7
+ private size = 0
8
+ private start = 0
9
+
10
+ constructor(private readonly maxBytes = 1_000_000) {}
11
+
12
+ /** Absolute offset one past the last byte ever written. */
13
+ get end(): number {
14
+ return this.start + this.size
15
+ }
16
+
17
+ append(chunk: Uint8Array): number {
18
+ const offset = this.end
19
+ this.chunks.push(chunk)
20
+ this.size += chunk.byteLength
21
+ while (this.size > this.maxBytes && this.chunks.length > 1) {
22
+ const dropped = this.chunks.shift() as Uint8Array
23
+ this.size -= dropped.byteLength
24
+ this.start += dropped.byteLength
25
+ }
26
+ return offset
27
+ }
28
+
29
+ /** Bytes from `offset` (clamped to what is retained) to the end. */
30
+ since(offset = 0): { offset: number; bytes: Uint8Array } {
31
+ const from = Math.max(offset, this.start)
32
+ const out = new Uint8Array(this.end - from)
33
+ let cursor = this.start
34
+ let written = 0
35
+ for (const chunk of this.chunks) {
36
+ const chunkEnd = cursor + chunk.byteLength
37
+ if (chunkEnd > from) {
38
+ const slice = chunk.subarray(Math.max(0, from - cursor))
39
+ out.set(slice, written)
40
+ written += slice.byteLength
41
+ }
42
+ cursor = chunkEnd
43
+ }
44
+ return { offset: from, bytes: out }
45
+ }
46
+ }
@@ -0,0 +1,44 @@
1
+ import type { ScreenResult } from "@opencode-cockpit/protocol/shell"
2
+ import { Terminal } from "@xterm/headless"
3
+
4
+ /** Full VT emulation of a shell's output: what a human would see right now (ADR 0003). */
5
+ export class Screen {
6
+ private readonly term: Terminal
7
+
8
+ constructor(cols: number, rows: number, scrollback = 2000) {
9
+ this.term = new Terminal({ cols, rows, scrollback, allowProposedApi: true })
10
+ }
11
+
12
+ write(chunk: Uint8Array): void {
13
+ this.term.write(chunk)
14
+ }
15
+
16
+ resize(cols: number, rows: number): void {
17
+ this.term.resize(cols, rows)
18
+ }
19
+
20
+ reset(): void {
21
+ this.term.reset()
22
+ }
23
+
24
+ /** Waits until every pending write has been parsed, then renders the viewport. */
25
+ async snapshot(): Promise<ScreenResult> {
26
+ await new Promise<void>((resolve) => this.term.write("", resolve))
27
+ const buffer = this.term.buffer.active
28
+ const rows: string[] = []
29
+ for (let y = 0; y < this.term.rows; y++) {
30
+ rows.push(buffer.getLine(buffer.baseY + y)?.translateToString(true) ?? "")
31
+ }
32
+ while (rows.length > 0 && rows[rows.length - 1] === "") rows.pop()
33
+ return {
34
+ text: rows.join("\n"),
35
+ cols: this.term.cols,
36
+ rows: this.term.rows,
37
+ cursor: { x: buffer.cursorX, y: buffer.cursorY },
38
+ }
39
+ }
40
+
41
+ dispose(): void {
42
+ this.term.dispose()
43
+ }
44
+ }