summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src
diff options
context:
space:
mode:
authorHalil Tezcan KARABULUT <[email protected]>2026-01-21 15:49:46 +0300
committerGitHub <[email protected]>2026-01-21 06:49:46 -0600
commit87d91c29e23d136436342542f83a20bfc4a3d0bf (patch)
tree390b43083bec0c662d1df2d490a7b46bc9102680 /packages/app/src
parent259b2a3c2dbb785dd46c83e243f20436a05e3021 (diff)
downloadopencode-87d91c29e23d136436342542f83a20bfc4a3d0bf.tar.gz
opencode-87d91c29e23d136436342542f83a20bfc4a3d0bf.zip
fix(app): terminal improvements - focus, rename, error state, CSP (#9700)
Diffstat (limited to 'packages/app/src')
-rw-r--r--packages/app/src/components/session/session-sortable-terminal-tab.tsx136
-rw-r--r--packages/app/src/components/terminal.tsx12
-rw-r--r--packages/app/src/context/terminal.tsx48
-rw-r--r--packages/app/src/pages/session.tsx200
4 files changed, 336 insertions, 60 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 0e387b9fb..63efa54a8 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
<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>
)
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
index f19366b8a..1ab171898 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 8bde12da1..147c4f8f7 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<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(),
}
},
})
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 562176c1b..a75a0f0c1 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(
@@ -403,6 +413,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,
(lastId, prevLastId) => {
if (lastId && prevLastId && lastId > prevLastId) {
@@ -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) => {