Преглед изворни кода

fix(app): terminal improvements - focus, rename, error state, CSP (#9700)

Halil Tezcan KARABULUT пре 1 месец
родитељ
комит
87d91c29e2

+ 131 - 5
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
     <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
       <div class="relative h-full">
         <Tabs.Trigger
           value={props.terminal.id}
+          onClick={focus}
+          onMouseDown={(e) => e.preventDefault()}
+          onContextMenu={menu}
           closeButton={
-            terminal.all().length > 1 && (
-              <IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} />
-            )
+            <IconButton
+              icon="close"
+              variant="ghost"
+              onClick={(e) => {
+                e.stopPropagation()
+                close()
+              }}
+            />
           }
         >
-          {label()}
+          <span onDblClick={edit} style={{ visibility: editing() ? "hidden" : "visible" }}>
+            {label()}
+          </span>
         </Tabs.Trigger>
+        <Show when={editing()}>
+          <div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
+            <input
+              id={`terminal-title-input-${props.terminal.id}`}
+              type="text"
+              value={title()}
+              onInput={(e) => 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"
+            />
+          </div>
+        </Show>
+        <DropdownMenu open={menuOpen()} onOpenChange={setMenuOpen}>
+          <DropdownMenu.Portal>
+            <DropdownMenu.Content
+              style={{
+                position: "fixed",
+                left: `${menuPosition().x}px`,
+                top: `${menuPosition().y}px`,
+              }}
+            >
+              <DropdownMenu.Item onSelect={edit}>
+                <Icon name="edit" class="w-4 h-4 mr-2" />
+                Rename
+              </DropdownMenu.Item>
+              <DropdownMenu.Item onSelect={close}>
+                <Icon name="close" class="w-4 h-4 mr-2" />
+                Close
+              </DropdownMenu.Item>
+            </DropdownMenu.Content>
+          </DropdownMenu.Portal>
+        </DropdownMenu>
       </div>
     </div>
   )

+ 8 - 4
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 ?? {}),

+ 33 - 15
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<typeof useSDK>, 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<typeof useSDK>, dir: string, sess
         })
     },
     update(pty: Partial<LocalPTY> & { 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<typeof useSDK>, 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(),
     }
   },
 })

+ 164 - 36
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() {
 
       <Show when={isDesktop() && view().terminal.opened()}>
         <div
-          class="relative w-full flex-col shrink-0 border-t border-border-weak-base"
+          class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
           style={{ height: `${layout.terminal.height()}px` }}
         >
           <ResizeHandle
@@ -1896,29 +1952,101 @@ export default function Page() {
             >
               <DragDropSensors />
               <ConstrainDragYAxis />
-              <Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
-                <Tabs.List class="h-10">
-                  <SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
-                    <For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
-                  </SortableProvider>
-                  <div class="h-full flex items-center justify-center">
-                    <TooltipKeybind
-                      title={language.t("command.terminal.new")}
-                      keybind={command.keybind("terminal.new")}
-                      class="flex items-center"
-                    >
-                      <IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
-                    </TooltipKeybind>
-                  </div>
-                </Tabs.List>
-                <For each={terminal.all()}>
-                  {(pty) => (
-                    <Tabs.Content value={pty.id}>
-                      <Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
-                    </Tabs.Content>
-                  )}
-                </For>
-              </Tabs>
+              <div class="flex flex-col h-full">
+                <Tabs
+                  variant="alt"
+                  value={terminal.active()}
+                  onChange={(id) => {
+                    // Only switch tabs if not in the middle of starting edit mode
+                    terminal.open(id)
+                  }}
+                  class="!h-auto !flex-none"
+                >
+                  <Tabs.List class="h-10">
+                    <SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
+                      <For each={terminal.all()}>
+                        {(pty) => (
+                          <SortableTerminalTab
+                            terminal={pty}
+                            onClose={() => {
+                              view().terminal.close()
+                              setAutoCreated(false)
+                            }}
+                          />
+                        )}
+                      </For>
+                    </SortableProvider>
+                    <div class="h-full flex items-center justify-center">
+                      <TooltipKeybind
+                        title={language.t("command.terminal.new")}
+                        keybind={command.keybind("terminal.new")}
+                        class="flex items-center"
+                      >
+                        <IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
+                      </TooltipKeybind>
+                    </div>
+                  </Tabs.List>
+                </Tabs>
+                <div class="flex-1 min-h-0 relative">
+                  <For each={terminal.all()}>
+                    {(pty) => {
+                      const [dismissed, setDismissed] = createSignal(false)
+                      return (
+                        <div
+                          id={`terminal-wrapper-${pty.id}`}
+                          class="absolute inset-0"
+                          style={{
+                            display: terminal.active() === pty.id ? "block" : "none",
+                          }}
+                        >
+                          <Terminal
+                            pty={pty}
+                            onCleanup={(data) => terminal.update({ ...data, id: pty.id })}
+                            onConnectError={() => {
+                              terminal.update({ id: pty.id, error: true })
+                            }}
+                          />
+                          <Show when={pty.error && !dismissed()}>
+                            <div
+                              class="absolute inset-0 flex flex-col items-center justify-center gap-3"
+                              style={{ "background-color": "rgba(0, 0, 0, 0.6)" }}
+                            >
+                              <Icon
+                                name="circle-ban-sign"
+                                class="w-8 h-8"
+                                style={{ color: "rgba(239, 68, 68, 0.8)" }}
+                              />
+                              <div class="text-center" style={{ color: "rgba(255, 255, 255, 0.7)" }}>
+                                <div class="text-14-semibold mb-1">Connection Lost</div>
+                                <div class="text-12-regular" style={{ color: "rgba(255, 255, 255, 0.5)" }}>
+                                  The terminal connection was interrupted. This can happen when the server restarts.
+                                </div>
+                              </div>
+                              <button
+                                class="mt-2 px-3 py-1.5 text-12-medium rounded-lg transition-colors"
+                                style={{
+                                  "background-color": "rgba(255, 255, 255, 0.1)",
+                                  color: "rgba(255, 255, 255, 0.7)",
+                                  border: "1px solid rgba(255, 255, 255, 0.2)",
+                                }}
+                                onMouseEnter={(e) =>
+                                  (e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.15)")
+                                }
+                                onMouseLeave={(e) =>
+                                  (e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.1)")
+                                }
+                                onClick={() => setDismissed(true)}
+                              >
+                                Dismiss
+                              </button>
+                            </div>
+                          </Show>
+                        </div>
+                      )
+                    }}
+                  </For>
+                </div>
+              </div>
               <DragOverlay>
                 <Show when={store.activeTerminalDraggable}>
                   {(draggedId) => {

+ 10 - 1
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<string, string>
+    const env = {
+      ...process.env,
+      ...input.env,
+      TERM: "xterm-256color",
+      OPENCODE_TERMINAL: "1",
+    } as Record<string, string>
     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()

+ 2 - 1
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,