summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-03-10 11:29:57 -0500
committerAdam <[email protected]>2026-03-10 13:00:14 -0500
commit835a27cf517fae5d9952c30989de8be8f760d7a5 (patch)
tree94c5ec763e09517d42ce86a960264854c139f2f3
parent85afaaa13d693f400d8ec8e257fec086a58b68c1 (diff)
downloadopencode-835a27cf517fae5d9952c30989de8be8f760d7a5.tar.gz
opencode-835a27cf517fae5d9952c30989de8be8f760d7a5.zip
fix(app): terminal jank
-rw-r--r--packages/app/src/components/session/session-header.tsx75
-rw-r--r--packages/app/src/components/terminal.tsx5
-rw-r--r--packages/app/src/pages/session.tsx11
-rw-r--r--packages/app/src/pages/session/terminal-panel.tsx292
4 files changed, 208 insertions, 175 deletions
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx
index 9b4551584..97f0530e9 100644
--- a/packages/app/src/components/session/session-header.tsx
+++ b/packages/app/src/components/session/session-header.tsx
@@ -21,6 +21,8 @@ import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
+import { useTerminal } from "@/context/terminal"
+import { focusTerminalById } from "@/pages/session/helpers"
import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist"
import { StatusPopover } from "../status-popover"
@@ -229,6 +231,7 @@ export function SessionHeader() {
const sync = useSync()
const platform = usePlatform()
const language = useLanguage()
+ const terminal = useTerminal()
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
@@ -296,6 +299,16 @@ export function SessionHeader() {
] as const
})
+ const toggleTerminal = () => {
+ const next = !view().terminal.opened()
+ view().terminal.toggle()
+ if (!next) return
+
+ const id = terminal.active()
+ if (!id) return
+ focusTerminalById(id)
+ }
+
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const [menu, setMenu] = createStore({ open: false })
const [openRequest, setOpenRequest] = createStore({
@@ -617,39 +630,39 @@ export function SessionHeader() {
</div>
</Show>
<div class="flex items-center gap-1">
- <div class="hidden md:flex items-center gap-1 shrink-0">
- <TooltipKeybind
- title={language.t("command.terminal.toggle")}
- keybind={command.keybind("terminal.toggle")}
+ <TooltipKeybind
+ title={language.t("command.terminal.toggle")}
+ keybind={command.keybind("terminal.toggle")}
+ >
+ <Button
+ variant="ghost"
+ class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
+ onClick={toggleTerminal}
+ aria-label={language.t("command.terminal.toggle")}
+ aria-expanded={view().terminal.opened()}
+ aria-controls="terminal-panel"
>
- <Button
- variant="ghost"
- class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border"
- onClick={() => view().terminal.toggle()}
- aria-label={language.t("command.terminal.toggle")}
- aria-expanded={view().terminal.opened()}
- aria-controls="terminal-panel"
- >
- <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
- <Icon
- size="small"
- name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
- class="group-hover/terminal-toggle:hidden"
- />
- <Icon
- size="small"
- name="layout-bottom-partial"
- class="hidden group-hover/terminal-toggle:inline-block"
- />
- <Icon
- size="small"
- name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
- class="hidden group-active/terminal-toggle:inline-block"
- />
- </div>
- </Button>
- </TooltipKeybind>
+ <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+ <Icon
+ size="small"
+ name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
+ class="group-hover/terminal-toggle:hidden"
+ />
+ <Icon
+ size="small"
+ name="layout-bottom-partial"
+ class="hidden group-hover/terminal-toggle:inline-block"
+ />
+ <Icon
+ size="small"
+ name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
+ class="hidden group-active/terminal-toggle:inline-block"
+ />
+ </div>
+ </Button>
+ </TooltipKeybind>
+ <div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")}
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
index 9e5f12ee4..120af0a17 100644
--- a/packages/app/src/components/terminal.tsx
+++ b/packages/app/src/components/terminal.tsx
@@ -17,6 +17,7 @@ const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
+ autoFocus?: boolean
onSubmit?: () => void
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
onConnect?: () => void
@@ -157,7 +158,7 @@ export const Terminal = (props: TerminalProps) => {
const language = useLanguage()
const server = useServer()
let container!: HTMLDivElement
- const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
+ const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
const id = local.pty.id
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize =
@@ -386,7 +387,7 @@ export const Terminal = (props: TerminalProps) => {
handleLinkClick,
})
- focusTerminal()
+ if (local.autoFocus !== false) focusTerminal()
if (typeof document !== "undefined" && document.fonts) {
document.fonts.ready.then(scheduleFit)
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index c1552ad02..79c8d42f5 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -32,8 +32,9 @@ import { useLayout } from "@/context/layout"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
+import { useTerminal } from "@/context/terminal"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
-import { createOpenReviewFile, createSizing } from "@/pages/session/helpers"
+import { createOpenReviewFile, createSizing, focusTerminalById } from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/message-timeline"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers"
@@ -267,6 +268,7 @@ export default function Page() {
const sdk = useSDK()
const prompt = usePrompt()
const comments = useComments()
+ const terminal = useTerminal()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
createEffect(() => {
@@ -759,8 +761,11 @@ export default function Page() {
return
}
- // Don't autofocus chat if desktop terminal panel is open
- if (isDesktop() && view().terminal.opened()) return
+ // Prefer the open terminal over the composer when it can take focus
+ if (view().terminal.opened()) {
+ const id = terminal.active()
+ if (id && focusTerminalById(id)) return
+ }
// Only treat explicit scroll keys as potential "user scroll" gestures.
if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {
diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx
index 19a656b53..a6c3929c1 100644
--- a/packages/app/src/pages/session/terminal-panel.tsx
+++ b/packages/app/src/pages/session/terminal-panel.tsx
@@ -1,6 +1,5 @@
import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
-import { createMediaQuery } from "@solid-primitives/media"
import { useParams } from "@solidjs/router"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
@@ -27,12 +26,10 @@ export function TerminalPanel() {
const language = useLanguage()
const command = useCommand()
- const isDesktop = createMediaQuery("(min-width: 768px)")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
const opened = createMemo(() => view().terminal.opened())
- const open = createMemo(() => isDesktop() && opened())
const size = createSizing()
const height = createMemo(() => layout.terminal.height())
const close = () => view().terminal.close()
@@ -41,6 +38,25 @@ export function TerminalPanel() {
const [store, setStore] = createStore({
autoCreated: false,
activeDraggable: undefined as string | undefined,
+ view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
+ })
+
+ const max = () => store.view * 0.6
+ const pane = () => Math.min(height(), max())
+
+ createEffect(() => {
+ if (typeof window === "undefined") return
+
+ const sync = () => setStore("view", window.visualViewport?.height ?? window.innerHeight)
+ const port = window.visualViewport
+
+ sync()
+ window.addEventListener("resize", sync)
+ port?.addEventListener("resize", sync)
+ onCleanup(() => {
+ window.removeEventListener("resize", sync)
+ port?.removeEventListener("resize", sync)
+ })
})
createEffect(() => {
@@ -69,14 +85,14 @@ export function TerminalPanel() {
focusTerminalById(id)
const frame = requestAnimationFrame(() => {
- if (!open()) return
+ if (!opened()) return
if (terminal.active() !== id) return
focusTerminalById(id)
})
const timers = [120, 240].map((ms) =>
window.setTimeout(() => {
- if (!open()) return
+ if (!opened()) return
if (terminal.active() !== id) return
focusTerminalById(id)
}, ms),
@@ -90,7 +106,7 @@ export function TerminalPanel() {
createEffect(
on(
- () => [open(), terminal.active()] as const,
+ () => [opened(), terminal.active()] as const,
([next, id]) => {
if (!next || !id) return
const stop = focus(id)
@@ -100,7 +116,7 @@ export function TerminalPanel() {
)
createEffect(() => {
- if (open()) return
+ if (opened()) return
const active = document.activeElement
if (!(active instanceof HTMLElement)) return
if (!root?.contains(active)) return
@@ -165,151 +181,149 @@ export function TerminalPanel() {
}
return (
- <Show when={isDesktop()}>
+ <div
+ ref={root}
+ id="terminal-panel"
+ role="region"
+ aria-label={language.t("terminal.title")}
+ aria-hidden={!opened()}
+ inert={!opened()}
+ class="relative w-full shrink-0 overflow-hidden bg-background-stronger"
+ classList={{
+ "border-t border-border-weak-base": opened(),
+ "transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
+ !size.active(),
+ }}
+ style={{ height: opened() ? `${pane()}px` : "0px" }}
+ >
<div
- ref={root}
- id="terminal-panel"
- role="region"
- aria-label={language.t("terminal.title")}
- aria-hidden={!open()}
- inert={!open()}
- class="relative w-full shrink-0 overflow-hidden"
+ class="absolute inset-x-0 top-0 flex flex-col"
classList={{
- "transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
+ "translate-y-0": opened(),
+ "translate-y-full pointer-events-none": !opened(),
+ "transition-transform duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform motion-reduce:transition-none":
!size.active(),
}}
- style={{ height: open() ? `${height()}px` : "0px" }}
+ style={{ height: `${pane()}px` }}
>
- <div
- class="absolute inset-x-0 top-0 flex flex-col border-t border-border-weak-base"
- classList={{
- "translate-y-0 opacity-100": open(),
- "translate-y-full opacity-0 pointer-events-none": !open(),
- "transition-[transform,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[transform,opacity] motion-reduce:transition-none":
- !size.active(),
- }}
- style={{ height: `${height()}px` }}
- >
- <div onPointerDown={() => size.start()}>
- <ResizeHandle
- direction="vertical"
- size={height()}
- min={100}
- max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
- collapseThreshold={50}
- onResize={(next) => {
- size.touch()
- layout.terminal.resize(next)
- }}
- onCollapse={close}
- />
- </div>
- <Show
- when={terminal.ready()}
- fallback={
- <div class="flex flex-col h-full pointer-events-none">
- <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
- <For each={handoff()}>
- {(title) => (
- <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
- {title}
- </div>
- )}
- </For>
- <div class="flex-1" />
- <div class="text-text-weak pr-2">
- {language.t("common.loading")}
- {language.t("common.loading.ellipsis")}
- </div>
- </div>
- <div class="flex-1 flex items-center justify-center text-text-weak">
- {language.t("terminal.loading")}
- </div>
- </div>
- }
- >
- <DragDropProvider
- onDragStart={handleTerminalDragStart}
- onDragEnd={handleTerminalDragEnd}
- onDragOver={handleTerminalDragOver}
- collisionDetector={closestCenter}
- >
- <DragDropSensors />
- <ConstrainDragYAxis />
- <div class="flex flex-col h-full">
- <Tabs
- variant="alt"
- value={terminal.active()}
- onChange={(id) => terminal.open(id)}
- class="!h-auto !flex-none"
- >
- <Tabs.List class="h-10 border-b border-border-weaker-base">
- <SortableProvider ids={ids()}>
- <For each={ids()}>
- {(id) => (
- <Show when={byId().get(id)}>
- {(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
- </Show>
- )}
- </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}
- aria-label={language.t("command.terminal.new")}
- />
- </TooltipKeybind>
+ <div class="hidden md:block" onPointerDown={() => size.start()}>
+ <ResizeHandle
+ direction="vertical"
+ size={pane()}
+ min={100}
+ max={max()}
+ collapseThreshold={50}
+ onResize={(next) => {
+ size.touch()
+ layout.terminal.resize(next)
+ }}
+ onCollapse={close}
+ />
+ </div>
+ <Show
+ when={terminal.ready()}
+ fallback={
+ <div class="flex flex-col h-full pointer-events-none">
+ <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
+ <For each={handoff()}>
+ {(title) => (
+ <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
+ {title}
</div>
- </Tabs.List>
- </Tabs>
- <div class="flex-1 min-h-0 relative">
- <Show when={terminal.active()} keyed>
- {(id) => (
- <Show when={byId().get(id)}>
- {(pty) => (
- <div id={`terminal-wrapper-${id}`} class="absolute inset-0">
- <Terminal
- pty={pty()}
- onConnect={() => terminal.trim(id)}
- onCleanup={terminal.update}
- onConnectError={() => terminal.clone(id)}
- />
- </div>
- )}
- </Show>
- )}
- </Show>
+ )}
+ </For>
+ <div class="flex-1" />
+ <div class="text-text-weak pr-2">
+ {language.t("common.loading")}
+ {language.t("common.loading.ellipsis")}
</div>
</div>
- <DragOverlay>
- <Show when={store.activeDraggable}>
- {(draggedId) => (
- <Show when={byId().get(draggedId())}>
- {(t) => (
- <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
- {terminalTabLabel({
- title: t().title,
- titleNumber: t().titleNumber,
- t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
- })}
+ <div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div>
+ </div>
+ }
+ >
+ <DragDropProvider
+ onDragStart={handleTerminalDragStart}
+ onDragEnd={handleTerminalDragEnd}
+ onDragOver={handleTerminalDragOver}
+ collisionDetector={closestCenter}
+ >
+ <DragDropSensors />
+ <ConstrainDragYAxis />
+ <div class="flex flex-col h-full">
+ <Tabs
+ variant="alt"
+ value={terminal.active()}
+ onChange={(id) => terminal.open(id)}
+ class="!h-auto !flex-none"
+ >
+ <Tabs.List class="h-10 border-b border-border-weaker-base">
+ <SortableProvider ids={ids()}>
+ <For each={ids()}>
+ {(id) => (
+ <Show when={byId().get(id)}>
+ {(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
+ </Show>
+ )}
+ </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}
+ aria-label={language.t("command.terminal.new")}
+ />
+ </TooltipKeybind>
+ </div>
+ </Tabs.List>
+ </Tabs>
+ <div class="flex-1 min-h-0 relative">
+ <Show when={terminal.active()} keyed>
+ {(id) => (
+ <Show when={byId().get(id)}>
+ {(pty) => (
+ <div id={`terminal-wrapper-${id}`} class="absolute inset-0">
+ <Terminal
+ pty={pty()}
+ autoFocus={opened()}
+ onConnect={() => terminal.trim(id)}
+ onCleanup={terminal.update}
+ onConnectError={() => terminal.clone(id)}
+ />
</div>
)}
</Show>
)}
</Show>
- </DragOverlay>
- </DragDropProvider>
- </Show>
- </div>
+ </div>
+ </div>
+ <DragOverlay>
+ <Show when={store.activeDraggable}>
+ {(draggedId) => (
+ <Show when={byId().get(draggedId())}>
+ {(t) => (
+ <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
+ {terminalTabLabel({
+ title: t().title,
+ titleNumber: t().titleNumber,
+ t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
+ })}
+ </div>
+ )}
+ </Show>
+ )}
+ </Show>
+ </DragOverlay>
+ </DragDropProvider>
+ </Show>
</div>
- </Show>
+ </div>
)
}