From 583c259d411a97371ab3c9c68aef0540829f6b09 Mon Sep 17 00:00:00 2001 From: Halil Tezcan KARABULUT Date: Wed, 21 Jan 2026 00:07:45 +0300 Subject: [PATCH] fix(app): terminal improvements - exit handling, focus, rename, error state - Close terminal tabs automatically when shell exits - Fix freeze when closing middle tabs - Auto-close terminal panel when last tab is closed - Fix keyboard focus going to terminal instead of chat - Add terminal error state UI showing 'Connection Lost' message - Add terminal rename via double-click or context menu - Add OPENCODE_TERMINAL=1 env var for PTY sessions - Update CSP to allow wasm-unsafe-eval for ghostty terminal --- .../session/session-sortable-terminal-tab.tsx | 136 +++++++++++- packages/app/src/components/terminal.tsx | 12 +- packages/app/src/context/terminal.tsx | 48 +++-- packages/app/src/pages/session.tsx | 200 ++++++++++++++---- packages/opencode/src/pty/index.ts | 11 +- packages/opencode/src/server/server.ts | 3 +- 6 files changed, 348 insertions(+), 62 deletions(-) diff --git a/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/packages/app/src/components/session/session-sortable-terminal-tab.tsx index 0e387b9fb1a..63efa54a846 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab.tsx +++ b/packages/app/src/components/session/session-sortable-terminal-tab.tsx @@ -1,14 +1,22 @@ import type { JSX } from "solid-js" +import { createSignal, Show } from "solid-js" import { createSortable } from "@thisbeyond/solid-dnd" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tabs } from "@opencode-ai/ui/tabs" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Icon } from "@opencode-ai/ui/icon" import { useTerminal, type LocalPTY } from "@/context/terminal" import { useLanguage } from "@/context/language" -export function SortableTerminalTab(props: { terminal: LocalPTY }): JSX.Element { +export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element { const terminal = useTerminal() const language = useLanguage() const sortable = createSortable(props.terminal.id) + const [editing, setEditing] = createSignal(false) + const [title, setTitle] = createSignal(props.terminal.title) + const [menuOpen, setMenuOpen] = createSignal(false) + const [menuPosition, setMenuPosition] = createSignal({ x: 0, y: 0 }) + const [blurEnabled, setBlurEnabled] = createSignal(false) const label = () => { language.locale() @@ -19,20 +27,138 @@ export function SortableTerminalTab(props: { terminal: LocalPTY }): JSX.Element if (props.terminal.title) return props.terminal.title return language.t("terminal.title") } + + const close = () => { + const count = terminal.all().length + terminal.close(props.terminal.id) + if (count === 1) { + props.onClose?.() + } + } + + const focus = () => { + if (editing()) return + + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } + const wrapper = document.getElementById(`terminal-wrapper-${props.terminal.id}`) + const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement + if (!element) return + + const textarea = element.querySelector("textarea") as HTMLTextAreaElement + if (textarea) { + textarea.focus() + return + } + element.focus() + element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true })) + } + + const edit = (e?: Event) => { + if (e) { + e.stopPropagation() + e.preventDefault() + } + + setBlurEnabled(false) + setTitle(props.terminal.title) + setEditing(true) + setTimeout(() => { + const input = document.getElementById(`terminal-title-input-${props.terminal.id}`) as HTMLInputElement + if (!input) return + input.focus() + input.select() + setTimeout(() => setBlurEnabled(true), 100) + }, 10) + } + + const save = () => { + if (!blurEnabled()) return + + const value = title().trim() + if (value && value !== props.terminal.title) { + terminal.update({ id: props.terminal.id, title: value }) + } + setEditing(false) + } + + const keydown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault() + save() + return + } + if (e.key === "Escape") { + e.preventDefault() + setEditing(false) + } + } + + const menu = (e: MouseEvent) => { + e.preventDefault() + setMenuPosition({ x: e.clientX, y: e.clientY }) + setMenuOpen(true) + } + return ( // @ts-ignore
e.preventDefault()} + onContextMenu={menu} closeButton={ - terminal.all().length > 1 && ( - terminal.close(props.terminal.id)} /> - ) + { + e.stopPropagation() + close() + }} + /> } > - {label()} + + {label()} + + +
+ setTitle(e.currentTarget.value)} + onBlur={save} + onKeyDown={keydown} + onMouseDown={(e) => e.stopPropagation()} + class="bg-transparent border-none outline-none text-sm min-w-0 flex-1" + /> +
+
+ + + + + + Rename + + + + Close + + + +
) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index f19366b8ab9..1ab17189829 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -241,7 +241,6 @@ export const Terminal = (props: TerminalProps) => { // console.log("Scroll position:", ydisp) // }) socket.addEventListener("open", () => { - console.log("WebSocket connected") sdk.client.pty .update({ ptyID: local.pty.id, @@ -257,10 +256,14 @@ export const Terminal = (props: TerminalProps) => { }) socket.addEventListener("error", (error) => { console.error("WebSocket error:", error) - props.onConnectError?.(error) + local.onConnectError?.(error) }) - socket.addEventListener("close", () => { - console.log("WebSocket disconnected") + socket.addEventListener("close", (event) => { + // Normal closure (code 1000) means PTY process exited - server event handles cleanup + // For other codes (network issues, server restart), trigger error handler + if (event.code !== 1000) { + local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`)) + } }) }) @@ -293,6 +296,7 @@ export const Terminal = (props: TerminalProps) => { ref={container} data-component="terminal" data-prevent-autofocus + tabIndex={-1} style={{ "background-color": terminalColors().background }} classList={{ ...(local.classList ?? {}), diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 8bde12da11b..147c4f8f7ea 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -13,6 +13,7 @@ export type LocalPTY = { cols?: number buffer?: string scrollY?: number + error?: boolean } const WORKSPACE_KEY = "__workspace__" @@ -107,14 +108,15 @@ function createTerminalSession(sdk: ReturnType, dir: string, sess .then((pty) => { const id = pty.data?.id if (!id) return - setStore("all", [ - ...store.all, - { - id, - title: pty.data?.title ?? "Terminal", - titleNumber: nextNumber, - }, - ]) + const newTerminal = { + id, + title: pty.data?.title ?? "Terminal", + titleNumber: nextNumber, + } + setStore("all", (all) => { + const newAll = [...all, newTerminal] + return newAll + }) setStore("active", id) }) .catch((e) => { @@ -122,7 +124,10 @@ function createTerminalSession(sdk: ReturnType, dir: string, sess }) }, update(pty: Partial & { id: string }) { - setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) + const index = store.all.findIndex((x) => x.id === pty.id) + if (index !== -1) { + setStore("all", index, (existing) => ({ ...existing, ...pty })) + } sdk.client.pty .update({ ptyID: pty.id, @@ -157,18 +162,29 @@ function createTerminalSession(sdk: ReturnType, dir: string, sess open(id: string) { setStore("active", id) }, + next() { + const index = store.all.findIndex((x) => x.id === store.active) + if (index === -1) return + const nextIndex = (index + 1) % store.all.length + setStore("active", store.all[nextIndex]?.id) + }, + previous() { + const index = store.all.findIndex((x) => x.id === store.active) + if (index === -1) return + const prevIndex = index === 0 ? store.all.length - 1 : index - 1 + setStore("active", store.all[prevIndex]?.id) + }, async close(id: string) { batch(() => { - setStore( - "all", - store.all.filter((x) => x.id !== id), - ) + const filtered = store.all.filter((x) => x.id !== id) if (store.active === id) { const index = store.all.findIndex((f) => f.id === id) - const previous = store.all[Math.max(0, index - 1)] - setStore("active", previous?.id) + const next = index > 0 ? index - 1 : 0 + setStore("active", filtered[next]?.id) } + setStore("all", filtered) }) + await sdk.client.pty.remove({ ptyID: id }).catch((e) => { console.error("Failed to close terminal", e) }) @@ -244,6 +260,8 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont open: (id: string) => workspace().open(id), close: (id: string) => workspace().close(id), move: (id: string, to: number) => workspace().move(id, to), + next: () => workspace().next(), + previous: () => workspace().previous(), } }, }) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 562176c1b7e..a75a0f0c1ed 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,4 +1,16 @@ -import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on, createSignal } from "solid-js" +import { + For, + Index, + onCleanup, + onMount, + Show, + Match, + Switch, + createMemo, + createEffect, + on, + createSignal, +} from "solid-js" import { createMediaQuery } from "@solid-primitives/media" import { createResizeObserver } from "@solid-primitives/resize-observer" import { Dynamic } from "solid-js/web" @@ -350,14 +362,7 @@ export default function Page() { const current = activeMessage() const currentIndex = current ? msgs.findIndex((m) => m.id === current.id) : -1 - - let targetIndex: number - if (currentIndex === -1) { - targetIndex = offset > 0 ? 0 : msgs.length - 1 - } else { - targetIndex = currentIndex + offset - } - + const targetIndex = currentIndex === -1 ? (offset > 0 ? 0 : msgs.length - 1) : currentIndex + offset if (targetIndex < 0 || targetIndex >= msgs.length) return scrollToMessage(msgs[targetIndex], "auto") @@ -381,11 +386,16 @@ export default function Page() { sync.session.sync(params.id) }) + const [autoCreated, setAutoCreated] = createSignal(false) + createEffect(() => { - if (!view().terminal.opened()) return - if (!terminal.ready()) return - if (terminal.all().length !== 0) return + if (!view().terminal.opened()) { + setAutoCreated(false) + return + } + if (!terminal.ready() || terminal.all().length !== 0 || autoCreated()) return terminal.new() + setAutoCreated(true) }) createEffect( @@ -401,6 +411,32 @@ export default function Page() { ), ) + createEffect( + on( + () => terminal.active(), + (activeId) => { + if (!activeId || !view().terminal.opened()) return + // Immediately remove focus + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } + const wrapper = document.getElementById(`terminal-wrapper-${activeId}`) + const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement + if (!element) return + + // Find and focus the ghostty textarea (the actual input element) + const textarea = element.querySelector("textarea") as HTMLTextAreaElement + if (textarea) { + textarea.focus() + return + } + // Fallback: focus container and dispatch pointer event + element.focus() + element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true })) + }, + ), + ) + createEffect( on( () => visibleUserMessages().at(-1)?.id, @@ -753,6 +789,9 @@ export default function Page() { return } + // Don't autofocus chat if terminal panel is open + if (view().terminal.opened()) return + if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) { inputRef?.focus() } @@ -800,6 +839,23 @@ export default function Page() { const handleTerminalDragEnd = () => { setStore("activeTerminalDraggable", undefined) + const activeId = terminal.active() + if (!activeId) return + setTimeout(() => { + const wrapper = document.getElementById(`terminal-wrapper-${activeId}`) + const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement + if (!element) return + + // Find and focus the ghostty textarea (the actual input element) + const textarea = element.querySelector("textarea") as HTMLTextAreaElement + if (textarea) { + textarea.focus() + return + } + // Fallback: focus container and dispatch pointer event + element.focus() + element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true })) + }, 0) } const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context")) @@ -1855,7 +1911,7 @@ export default function Page() {
- - - t.id)}> - {(pty) => } - -
- - - -
-
- - {(pty) => ( - - terminal.clone(pty.id)} /> - - )} - -
+
+ { + // Only switch tabs if not in the middle of starting edit mode + terminal.open(id) + }} + class="!h-auto !flex-none" + > + + t.id)}> + + {(pty) => ( + { + view().terminal.close() + setAutoCreated(false) + }} + /> + )} + + +
+ + + +
+
+
+
+ + {(pty) => { + const [dismissed, setDismissed] = createSignal(false) + return ( +
+ terminal.update({ ...data, id: pty.id })} + onConnectError={() => { + terminal.update({ id: pty.id, error: true }) + }} + /> + +
+ +
+
Connection Lost
+
+ The terminal connection was interrupted. This can happen when the server restarts. +
+
+ +
+
+
+ ) + }} +
+
+
{(draggedId) => { diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 6edff32e132..73474ed4f87 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -102,7 +102,12 @@ export namespace Pty { } const cwd = input.cwd || Instance.directory - const env = { ...process.env, ...input.env, TERM: "xterm-256color" } as Record + const env = { + ...process.env, + ...input.env, + TERM: "xterm-256color", + OPENCODE_TERMINAL: "1", + } as Record log.info("creating session", { id, cmd: command, args, cwd }) const spawn = await pty() @@ -146,6 +151,10 @@ export namespace Pty { ptyProcess.onExit(({ exitCode }) => { log.info("session exited", { id, exitCode }) session.info.status = "exited" + for (const ws of session.subscribers) { + ws.close() + } + session.subscribers.clear() Bus.publish(Event.Exited, { id, exitCode }) for (const ws of session.subscribers) { ws.close() diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 15b7f829b9c..fa646f21ea8 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -499,6 +499,7 @@ export namespace Server { ) .all("/*", async (c) => { const path = c.req.path + const response = await proxy(`https://app.opencode.ai${path}`, { ...c.req, headers: { @@ -508,7 +509,7 @@ export namespace Server { }) response.headers.set( "Content-Security-Policy", - "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'", + "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' data:", ) return response }) as unknown as Hono,