opencode-drive 0.1.7 → 0.1.8

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,124 @@
1
+ import type { TerminalCore } from "@wterm/core"
2
+ import { GhosttyCore } from "@wterm/ghostty"
3
+ import type { CapturedFrame, CapturedSpan } from "./types.js"
4
+
5
+ const DefaultForeground = 0xd8d8d8
6
+ const DefaultBackground = 0x080808
7
+ const SyncStart = Buffer.from("\x1b[?2026h")
8
+ const SyncEnd = Buffer.from("\x1b[?2026l")
9
+
10
+ export interface TerminalParser {
11
+ write(data: Uint8Array): void
12
+ finish(): void
13
+ resize(cols: number, rows: number): void
14
+ snapshot(): CapturedFrame
15
+ }
16
+
17
+ export type TerminalParserFactory = (cols: number, rows: number) => Promise<TerminalParser>
18
+
19
+ function characterWidth(char: string) {
20
+ return Math.max(1, Bun.stringWidth(char))
21
+ }
22
+
23
+ function capture(core: TerminalCore): CapturedFrame {
24
+ const cols = core.getCols()
25
+ const rows = core.getRows()
26
+ const lines = Array.from({ length: rows }, (_, row) => {
27
+ const spans: CapturedSpan[] = []
28
+ for (let col = 0; col < cols; ) {
29
+ const cell = core.getCell(row, col)
30
+ const text = String.fromCodePoint(cell.char || 32)
31
+ const width = Math.min(characterWidth(text), cols - col)
32
+ const next = {
33
+ text,
34
+ width,
35
+ fg: cell.fgRgb ?? DefaultForeground,
36
+ bg: cell.bgRgb ?? DefaultBackground,
37
+ attributes: cell.flags,
38
+ }
39
+ const previous = spans.at(-1)
40
+ if (
41
+ previous &&
42
+ previous.fg === next.fg &&
43
+ previous.bg === next.bg &&
44
+ previous.attributes === next.attributes
45
+ ) {
46
+ previous.text += next.text
47
+ previous.width += width
48
+ } else {
49
+ spans.push(next)
50
+ }
51
+ col += width
52
+ }
53
+ return { spans }
54
+ })
55
+ return { cols, rows, cursor: core.getCursor(), lines }
56
+ }
57
+
58
+ class GhosttyTerminal implements TerminalParser {
59
+ private synchronized = false
60
+ private stable?: CapturedFrame
61
+ private pending = Buffer.alloc(0)
62
+
63
+ constructor(private readonly core: TerminalCore) {}
64
+
65
+ write(data: Uint8Array) {
66
+ const input = this.pending.length ? Buffer.concat([this.pending, data]) : Buffer.from(data)
67
+ this.pending = Buffer.alloc(0)
68
+ let offset = 0
69
+ while (offset < input.length) {
70
+ const start = input.indexOf(SyncStart, offset)
71
+ const end = input.indexOf(SyncEnd, offset)
72
+ const marker = start === -1 ? end : end === -1 ? start : Math.min(start, end)
73
+ if (marker === -1) {
74
+ const keep = partialMarkerLength(input.subarray(offset))
75
+ const boundary = input.length - keep
76
+ if (boundary > offset) this.core.writeRaw(input.subarray(offset, boundary))
77
+ if (keep) this.pending = input.subarray(boundary)
78
+ return
79
+ }
80
+ if (marker > offset) this.core.writeRaw(input.subarray(offset, marker))
81
+ if (marker === start) {
82
+ if (!this.synchronized) this.stable = capture(this.core)
83
+ this.synchronized = true
84
+ this.core.writeRaw(SyncStart)
85
+ offset = marker + SyncStart.length
86
+ } else {
87
+ this.core.writeRaw(SyncEnd)
88
+ this.synchronized = false
89
+ this.stable = undefined
90
+ offset = marker + SyncEnd.length
91
+ }
92
+ }
93
+ }
94
+
95
+ finish() {
96
+ if (!this.pending.length) return
97
+ this.core.writeRaw(this.pending)
98
+ this.pending = Buffer.alloc(0)
99
+ }
100
+
101
+ resize(cols: number, rows: number) {
102
+ this.core.resize(cols, rows)
103
+ if (this.synchronized) this.stable = capture(this.core)
104
+ }
105
+
106
+ snapshot() {
107
+ return this.synchronized && this.stable ? structuredClone(this.stable) : capture(this.core)
108
+ }
109
+ }
110
+
111
+ function partialMarkerLength(input: Uint8Array) {
112
+ const limit = Math.min(input.length, Math.max(SyncStart.length, SyncEnd.length) - 1)
113
+ for (let length = limit; length > 0; length--) {
114
+ const suffix = input.subarray(input.length - length)
115
+ if (SyncStart.subarray(0, length).equals(suffix) || SyncEnd.subarray(0, length).equals(suffix)) return length
116
+ }
117
+ return 0
118
+ }
119
+
120
+ export const createTerminalParser: TerminalParserFactory = async (cols, rows) => {
121
+ const core: TerminalCore = await GhosttyCore.load()
122
+ core.init(cols, rows)
123
+ return new GhosttyTerminal(core)
124
+ }
@@ -0,0 +1,50 @@
1
+ export const TextStyle = {
2
+ bold: 1,
3
+ dim: 2,
4
+ italic: 4,
5
+ underline: 8,
6
+ blink: 16,
7
+ inverse: 32,
8
+ invisible: 64,
9
+ strikethrough: 128,
10
+ } as const
11
+
12
+ export interface CapturedSpan {
13
+ text: string
14
+ width: number
15
+ fg: number
16
+ bg: number
17
+ attributes: number
18
+ }
19
+
20
+ export interface CapturedLine {
21
+ spans: CapturedSpan[]
22
+ }
23
+
24
+ export interface CapturedFrame {
25
+ cols: number
26
+ rows: number
27
+ cursor: { row: number; col: number; visible: boolean }
28
+ lines: CapturedLine[]
29
+ }
30
+
31
+ export interface SampledFrame {
32
+ atMs: number
33
+ frame: CapturedFrame
34
+ }
35
+
36
+ export interface TimelineHeader {
37
+ type: "header"
38
+ version: 1
39
+ cols: number
40
+ rows: number
41
+ encoding: "base64"
42
+ }
43
+
44
+ export interface TimelineOutput {
45
+ type: "output"
46
+ at_ms: number
47
+ data: string
48
+ }
49
+
50
+ export type TimelineRecord = TimelineHeader | TimelineOutput
@@ -1,60 +0,0 @@
1
- import path from "node:path"
2
-
3
- const root = "/tmp/opencode-probe-stale-running-visible"
4
- const state = path.join(root, "state", "files")
5
- await Bun.$`rm -rf ${root}`.quiet()
6
- await Bun.$`mkdir -p ${path.join(state, ".config/opencode")} ${path.join(state, "src")}`.quiet()
7
-
8
- const config = {
9
- model: "simulation/sim-model",
10
- permissions: [{ action: "*", resource: "*", effect: "allow" }],
11
- providers: {
12
- simulation: {
13
- name: "Simulation",
14
- request: { body: { apiKey: "sim-key" } },
15
- models: {
16
- "sim-model": {
17
- name: "Simulated Model",
18
- api: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.openai.com/v1" },
19
- capabilities: { tools: true, input: ["text"], output: ["text"] },
20
- limit: { context: 128000, output: 16000 },
21
- },
22
- },
23
- },
24
- },
25
- }
26
-
27
- await Promise.all([
28
- Bun.write(path.join(state, "opencode.json"), `${JSON.stringify(config, undefined, 2)}\n`),
29
- Bun.write(path.join(state, ".config/opencode/opencode.json"), `${JSON.stringify(config, undefined, 2)}\n`),
30
- Bun.write(path.join(state, "src/example.ts"), "export const example = true\n"),
31
- ])
32
-
33
- const child = Bun.spawn([
34
- path.resolve("bin/opencode-sim"),
35
- "--state", path.join(root, "state"),
36
- "--anchor", path.join(root, "anchor"),
37
- "--renderer", "visible",
38
- "--driver", `bun ${path.resolve("src/experimental/stale-running-driver.ts")}`,
39
- "--",
40
- "bun", "run", "--conditions=browser", "--preload=@opentui/solid/preload",
41
- "/root/projects/opencode-latest/packages/cli/src/index.ts",
42
- ], {
43
- cwd: path.resolve("."),
44
- env: {
45
- ...processEnv(),
46
- OPENCODE_SIMULATION_DRIVER_LOG: path.join(root, "driver.log"),
47
- OPENCODE_SIMULATION_LOG: path.join(root, "simulation.log"),
48
- },
49
- stdin: "inherit",
50
- stdout: "inherit",
51
- stderr: "inherit",
52
- })
53
-
54
- const status = await child.exited
55
- if (status !== 0) throw new Error(`visible reproduction failed; see ${path.join(root, "driver.log")}`)
56
- console.log(`Reproduction artifacts: ${root}`)
57
-
58
- function processEnv(): Record<string, string> {
59
- return Object.fromEntries(Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined))
60
- }
@@ -1,26 +0,0 @@
1
- const child = Bun.spawn([
2
- "bun",
3
- "test",
4
- "test/cli/tui/data.test.tsx",
5
- "--test-name-pattern",
6
- "clears stale running status after reconnect",
7
- ], {
8
- cwd: "/root/projects/opencode-latest/packages/tui",
9
- stdout: "pipe",
10
- stderr: "pipe",
11
- })
12
-
13
- const [status, stdout, stderr] = await Promise.all([
14
- child.exited,
15
- new Response(child.stdout).text(),
16
- new Response(child.stderr).text(),
17
- ])
18
- const output = `${stdout}${stderr}`
19
- process.stdout.write(output)
20
-
21
- if (status !== 0 && output.includes('Expected: "idle"') && output.includes('Received: "running"')) {
22
- console.log("\nREPRODUCED: reconnect left an inactive session stuck in the running UI state.")
23
- process.exit(0)
24
- }
25
-
26
- throw new Error(`reproduction did not produce the expected stale-running failure (test exit ${status})`)
@@ -1,106 +0,0 @@
1
- import {
2
- connectBackendSimulation,
3
- connectSimulation,
4
- type OpenedExchange,
5
- } from "../client/index.js"
6
- import { isRunning } from "./flows/index.js"
7
-
8
- const ui = await connectSimulation({
9
- url: requiredEnv("OPENCODE_SIMULATION_UI_WS"),
10
- })
11
- const backend = await connectBackendSimulation({
12
- url: requiredEnv("OPENCODE_SIMULATION_BACKEND_WS"),
13
- })
14
- const requestOpened = deferred()
15
- const releaseResponse = deferred()
16
- const responseFinished = deferred()
17
-
18
- await backend.attach(async (request: OpenedExchange) => {
19
- if (isTitleRequest(request)) {
20
- await backend.chunk(request.id, [
21
- { type: "textDelta", text: "Stale running reproduction" },
22
- ])
23
- await backend.finish(request.id, "stop")
24
- return
25
- }
26
- requestOpened.resolve()
27
- await backend.chunk(request.id, [
28
- {
29
- type: "textDelta",
30
- text: "The provider turn is finishing while events are disconnected.",
31
- },
32
- ])
33
- await releaseResponse.promise
34
- await backend.finish(request.id, "stop")
35
- responseFinished.resolve()
36
- })
37
-
38
- try {
39
- await waitFor("prompt editor", async () => (await ui.state()).focused.editor)
40
- await ui.typeText(
41
- "Reproduce stale running status across an event-stream reconnect",
42
- )
43
- await ui.pressEnter()
44
- await requestOpened.promise
45
- await waitFor("running TUI", async () => isRunning(await ui.state()))
46
-
47
- releaseResponse.resolve()
48
- await responseFinished.promise
49
- await Bun.sleep(1_000)
50
-
51
- const state = await ui.state()
52
- if (!isRunning(state))
53
- throw new Error("stale running status was not reproduced")
54
- console.log(
55
- "REPRODUCED: backend provider work is idle while the TUI still displays running.",
56
- )
57
- await Bun.sleep(Number(process.env.OPENCODE_PROBE_HOLD_MS ?? "10000"))
58
- } finally {
59
- ui.close()
60
- backend.close()
61
- }
62
-
63
- function deferred() {
64
- let resolve!: () => void
65
- const promise = new Promise<void>((done) => {
66
- resolve = done
67
- })
68
- return { promise, resolve }
69
- }
70
-
71
- function requiredEnv(name: string) {
72
- const value = process.env[name]
73
- if (value === undefined) throw new Error(`${name} is required`)
74
- return value
75
- }
76
-
77
- function isTitleRequest(request: OpenedExchange) {
78
- if (
79
- typeof request.body !== "object" ||
80
- request.body === null ||
81
- !("messages" in request.body)
82
- )
83
- return false
84
- const messages = request.body.messages
85
- if (!Array.isArray(messages)) return false
86
- const first = messages[0]
87
- if (typeof first !== "object" || first === null || !("content" in first))
88
- return false
89
- return (
90
- typeof first.content === "string" &&
91
- first.content.includes("You are a title generator")
92
- )
93
- }
94
-
95
- async function waitFor(
96
- label: string,
97
- check: () => Promise<boolean>,
98
- timeout = 30_000,
99
- ) {
100
- const deadline = Date.now() + timeout
101
- while (Date.now() < deadline) {
102
- if (await check()) return
103
- await Bun.sleep(50)
104
- }
105
- throw new Error(`timed out waiting for ${label}`)
106
- }