Skip to content

Commit ef712d3

Browse files
authored
feat(app): move the desktop terminal to persistent PTYs (#60346)
1 parent 664c8f0 commit ef712d3

106 files changed

Lines changed: 2420 additions & 4072 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎bun.lock‎

Lines changed: 1 addition & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/app/component-tests/terminal-focus.spec.ts‎

Lines changed: 0 additions & 56 deletions
This file was deleted.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import type { Locator, Page, WebSocketRoute } from "@playwright/test"
2+
import { expect, sourceURL, story } from "../../stories/playwright/story"
3+
4+
const fixture = sourceURL(new URL("../../gui-plugins/src/terminal/terminal.fixture.tsx", import.meta.url))
5+
6+
// No page loads anything from this origin; the routes below answer its terminal API and socket.
7+
const SERVER = "http://127.0.0.1:4599"
8+
9+
const SIZE = { cols: 80, rows: 24 }
10+
11+
type Frame = { type: number; data: string }
12+
13+
/**
14+
* A server with one terminal that already wrote `output`: it answers the snapshot and connect ticket, then speaks the
15+
* persistent PTY socket protocol at `inputProtocol`. A missing `output` is a terminal the server no longer has.
16+
*/
17+
async function serveTerminal(page: Page, input: { ptyID: string; output?: string; inputProtocol?: 0 | 1 }) {
18+
const frames: Frame[] = []
19+
const sockets: WebSocketRoute[] = []
20+
const tail = Buffer.byteLength(input.output ?? "")
21+
22+
const info = {
23+
id: input.ptyID,
24+
title: "Terminal 1",
25+
userTitle: null,
26+
command: "bash",
27+
args: [],
28+
cwd: "C:/OpenCode/Terminal",
29+
status: "running",
30+
pid: 1,
31+
sessionID: "ses_terminal",
32+
foregroundProcess: null,
33+
size: SIZE,
34+
output: { head: 0, tail },
35+
}
36+
37+
await page.route(`${SERVER}/api/experimental/persistent-pty/**`, (route) => {
38+
const headers = { "access-control-allow-origin": "*", "access-control-allow-headers": "*" }
39+
40+
if (route.request().method() === "OPTIONS") return route.fulfill({ status: 204, headers })
41+
42+
if (input.output === undefined) {
43+
return route.fulfill({
44+
status: 404,
45+
headers,
46+
// SAFETY: the server's wire body for a terminal it does not have.
47+
// oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction -- see SAFETY above
48+
json: { _tag: "PtyNotFoundError", ptyID: input.ptyID, message: `PTY session not found: ${input.ptyID}` },
49+
})
50+
}
51+
52+
if (route.request().url().endsWith("/snapshot")) {
53+
const checkpoint = Buffer.from(input.output).toString("base64")
54+
55+
return route.fulfill({
56+
headers,
57+
json: { data: { info, text: input.output, checkpoint, cursor: { x: 0, y: 0 } } },
58+
})
59+
}
60+
61+
return route.fulfill({ headers, json: { data: { ticket: "component-ticket", expires_in: 60 } } })
62+
})
63+
64+
// The socket mock patches the page's WebSocket when it loads, so this runs before the story mounts.
65+
await page.routeWebSocket(new RegExp(`/api/experimental/persistent-pty/${input.ptyID}/connect`), (ws) => {
66+
const url = new URL(ws.url())
67+
const attachmentID = url.searchParams.get("attachment_id") ?? ""
68+
sockets.push(ws)
69+
ws.onMessage((message) => {
70+
const data = Buffer.from(message)
71+
72+
frames.push({ type: data[0] ?? -1, data: data.subarray(5).toString() })
73+
})
74+
ws.send(
75+
JSON.stringify({
76+
type: "attached",
77+
attachmentID,
78+
inputProtocol: input.inputProtocol ?? 1,
79+
info,
80+
role: "controller",
81+
generation: 1,
82+
replay: { requestedOffset: tail, availableOffset: 0, endOffset: tail, truncated: false },
83+
}),
84+
)
85+
ws.send(JSON.stringify({ type: "replay_complete", endOffset: tail }))
86+
})
87+
88+
return { frames, sockets }
89+
}
90+
91+
async function mountTerminal(page: Page, mount: (id: string) => Promise<Locator>, ptyID: string) {
92+
// Any story loads the app styles; the fixture mounts the pane beside it.
93+
await mount("ui-line-comment--editor")
94+
await page.evaluate(
95+
async (input) => {
96+
const { mountTerminal } = await import(input.fixture)
97+
mountTerminal({ url: input.server, ptyID: input.ptyID })
98+
},
99+
{ fixture, server: SERVER, ptyID },
100+
)
101+
102+
return page.getByTestId("terminal-fixture")
103+
}
104+
105+
story("does not send the emulator's own answers to terminal queries as input", async ({ page, mount }) => {
106+
// Device status and attribute queries, in the restored screen and in live output. The daemon answers them.
107+
const server = await serveTerminal(page, { ptyID: "pty_queries", output: "restored\x1b[6n\x1b[c\r\n" })
108+
const root = await mountTerminal(page, mount, "pty_queries")
109+
110+
await expect.poll(() => server.sockets.length).toBe(1)
111+
await expect.poll(() => server.frames.some((frame) => frame.type === 0)).toBe(true)
112+
server.sockets[0]!.send(Buffer.from("live\x1b[6n\x1b[5n\x1b[c\r\n"))
113+
114+
// Typed input lands after the output was written, so any answer would already have been sent.
115+
await expect(root.locator("textarea")).toBeFocused()
116+
await page.keyboard.type("x")
117+
await expect.poll(() => server.frames.filter((frame) => frame.type === 1).length).toBeGreaterThan(0)
118+
expect(server.frames.filter((frame) => frame.type === 1).map((frame) => frame.data)).toEqual(["x"])
119+
})
120+
121+
story("tells the user a server that predates the input protocol is too old", async ({ page, mount }) => {
122+
await serveTerminal(page, { ptyID: "pty_old", output: "old server\r\n", inputProtocol: 0 })
123+
const root = await mountTerminal(page, mount, "pty_old")
124+
125+
await expect(
126+
root.getByText("This server is too old for terminals; update OpenCode on it", { exact: true }),
127+
).toBeVisible()
128+
await expect(root.locator("[data-failures]")).toHaveAttribute("data-failures", "1")
129+
})
130+
131+
story("says the connection failed when the terminal cannot be restored", async ({ page, mount }) => {
132+
const server = await serveTerminal(page, { ptyID: "pty_missing" })
133+
const root = await mountTerminal(page, mount, "pty_missing")
134+
135+
await expect(root.getByText("Terminal connection failed", { exact: true })).toBeVisible()
136+
await expect(root.locator("[data-failures]")).toHaveAttribute("data-failures", "1")
137+
expect(server.sockets).toEqual([])
138+
})

‎packages/app/e2e/performance/terminals/README.md‎

Lines changed: 0 additions & 44 deletions
This file was deleted.

‎packages/app/e2e/performance/terminals/playwright.config.ts‎

Lines changed: 0 additions & 20 deletions
This file was deleted.

‎packages/app/e2e/performance/terminals/probe.ts‎

Lines changed: 0 additions & 88 deletions
This file was deleted.

‎packages/app/e2e/performance/terminals/shell.ps1‎

Lines changed: 0 additions & 13 deletions
This file was deleted.

0 commit comments

Comments
 (0)