diff --git a/packages/desktop/src/addons/serialize.ts b/packages/desktop/src/addons/serialize.ts index 03899ff10..c9f848180 100644 --- a/packages/desktop/src/addons/serialize.ts +++ b/packages/desktop/src/addons/serialize.ts @@ -494,14 +494,9 @@ class StringSerializeHandler extends BaseSerializeHandler { } if (!excludeFinalCursorPosition) { - // Get cursor position relative to viewport (1-indexed for ANSI) - // cursorY is relative to the viewport, cursorX is column position - const cursorRow = this._buffer.cursorY + 1 // 1-indexed - const cursorCol = this._buffer.cursorX + 1 // 1-indexed - - // Use absolute cursor positioning (CUP - Cursor Position) - // This is more reliable than relative moves which depend on knowing - // exactly where the cursor ended up after all the content + const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY + const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER) + const cursorCol = this._buffer.cursorX + 1 content += `\u001b[${cursorRow};${cursorCol}H` } @@ -549,22 +544,20 @@ export class SerializeAddon implements ITerminalAddon { return "" } - const activeBuffer = buffer.active || buffer.normal - if (!activeBuffer) { + const normalBuffer = buffer.normal || buffer.active + const altBuffer = buffer.alternate + + if (!normalBuffer) { return "" } let content = options?.range - ? this._serializeBufferByRange(activeBuffer, options.range, true) - : this._serializeBufferByScrollback(activeBuffer, options?.scrollback) + ? this._serializeBufferByRange(normalBuffer, options.range, true) + : this._serializeBufferByScrollback(normalBuffer, options?.scrollback) - // Handle alternate buffer if active and not excluded - if (!options?.excludeAltBuffer) { - const altBuffer = buffer.alternate - if (altBuffer && buffer.active?.type === "alternate") { - const alternateContent = this._serializeBufferByScrollback(altBuffer, undefined) - content += `\u001b[?1049h\u001b[H${alternateContent}` - } + if (!options?.excludeAltBuffer && buffer.active?.type === "alternate" && altBuffer) { + const alternateContent = this._serializeBufferByScrollback(altBuffer, undefined) + content += `\u001b[?1049h\u001b[H${alternateContent}` } return content diff --git a/packages/desktop/src/components/terminal.tsx b/packages/desktop/src/components/terminal.tsx index 49a45a432..aaf5da880 100644 --- a/packages/desktop/src/components/terminal.tsx +++ b/packages/desktop/src/components/terminal.tsx @@ -1,6 +1,5 @@ import { init, Terminal as Term, FitAddon } from "ghostty-web" import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js" -import { createReconnectingWS, ReconnectingWebSocket } from "@solid-primitives/websocket" import { useSDK } from "@/context/sdk" import { SerializeAddon } from "@/addons/serialize" import { LocalPTY } from "@/context/session" @@ -11,19 +10,20 @@ export interface TerminalProps extends ComponentProps<"div"> { pty: LocalPTY onSubmit?: () => void onCleanup?: (pty: LocalPTY) => void + onConnectError?: (error: unknown) => void } export const Terminal = (props: TerminalProps) => { const sdk = useSDK() let container!: HTMLDivElement - const [local, others] = splitProps(props, ["pty", "class", "classList"]) - let ws: ReconnectingWebSocket + const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"]) + let ws: WebSocket let term: Term let serializeAddon: SerializeAddon let fitAddon: FitAddon onMount(async () => { - ws = createReconnectingWS(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`) + ws = new WebSocket(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`) term = new Term({ cursorBlink: true, fontSize: 14, @@ -52,19 +52,14 @@ export const Terminal = (props: TerminalProps) => { term.open(container) if (local.pty.buffer) { - const originalSize = { cols: term.cols, rows: term.rows } - let resized = false if (local.pty.rows && local.pty.cols) { term.resize(local.pty.cols, local.pty.rows) - resized = true } + term.reset() term.write(local.pty.buffer) if (local.pty.scrollY) { term.scrollToLine(local.pty.scrollY) } - if (resized) { - term.resize(originalSize.cols, originalSize.rows) - } } container.focus() @@ -115,6 +110,7 @@ export const Terminal = (props: TerminalProps) => { }) ws.addEventListener("error", (error) => { console.error("WebSocket error:", error) + props.onConnectError?.(error) }) ws.addEventListener("close", () => { console.log("WebSocket disconnected") diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx index 4e9fe71f8..b5972f3e3 100644 --- a/packages/desktop/src/context/session.tsx +++ b/packages/desktop/src/context/session.tsx @@ -26,7 +26,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex const params = useParams() const sync = useSync() const name = createMemo( - () => `______${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}`, + () => `${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}.v1`, ) const [store, setStore] = makePersisted( @@ -201,20 +201,14 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex sdk.client.pty.create({ body: { title: `Terminal ${store.terminals.all.length + 1}` } }).then((pty) => { const id = pty.data?.id if (!id) return - batch(() => { - setStore("terminals", "all", [ - ...store.terminals.all, - { - id, - title: pty.data?.title ?? "Terminal", - // rows: pty.data?.rows ?? 24, - // cols: pty.data?.cols ?? 80, - // buffer: "", - // scrollY: 0, - }, - ]) - setStore("terminals", "active", id) - }) + setStore("terminals", "all", [ + ...store.terminals.all, + { + id, + title: pty.data?.title ?? "Terminal", + }, + ]) + setStore("terminals", "active", id) }) }, update(pty: Partial & { id: string }) { @@ -224,6 +218,24 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex body: { title: pty.title, size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined }, }) }, + async clone(id: string) { + const index = store.terminals.all.findIndex((x) => x.id === id) + const pty = store.terminals.all[index] + if (!pty) return + const clone = await sdk.client.pty.create({ + body: { + title: pty.title, + }, + }) + if (!clone.data) return + setStore("terminals", "all", index, { + ...pty, + ...clone.data, + }) + if (store.terminals.active === pty.id) { + setStore("terminals", "active", clone.data.id) + } + }, open(id: string) { setStore("terminals", "active", id) }, diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 106a2e733..658ad4bcb 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -22,9 +22,10 @@ export default function Layout(props: ParentProps) { const layout = useLayout() const currentDirectory = createMemo(() => base64Decode(params.dir ?? "")) const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? []) - const currentSession = createMemo(() => sessions().find((s) => s.id === params.id) ?? sessions().at(0)) + const currentSession = createMemo(() => sessions().find((s) => s.id === params.id)) function navigateToSession(session: Session | undefined) { + if (!session) return navigate(`/${params.dir}/session/${session?.id}`) } @@ -59,6 +60,7 @@ export default function Layout(props: ParentProps) {