From 010996348e3e3d432661d0e53d8bcf92a3d9d38b Mon Sep 17 00:00:00 2001
From: Marco De Nichilo <36410465+marcodenic@users.noreply.github.com>
Date: Mon, 5 Jan 2026 01:37:52 +0000
Subject: [PATCH] Add terminal position toggle and allow thinner review panel
- Add terminal position state (bottom/right) to layout context
- Extract TerminalPanel into reusable component with position toggle button
- Conditionally render terminal at bottom (full width) or right (stacked with review)
- Increase session panel max width from 45% to 75% to allow thinner review panel
- Show right panel when terminal is in right position even without review tabs
- Terminal takes full height when no review tabs are present
- Add dynamic padding to chat content to account for terminal height
- Add keyboard shortcut (ctrl+shift+\) for terminal position toggle
---
packages/app/src/components/session/index.ts | 1 +
.../session/session-terminal-panel.tsx | 131 +++
packages/app/src/context/layout.tsx | 8 +
packages/app/src/pages/session.tsx | 754 ++++++++----------
4 files changed, 489 insertions(+), 405 deletions(-)
create mode 100644 packages/app/src/components/session/session-terminal-panel.tsx
diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts
index 20124b6fdef..085eb34e27a 100644
--- a/packages/app/src/components/session/index.ts
+++ b/packages/app/src/components/session/index.ts
@@ -3,3 +3,4 @@ export { SessionContextTab } from "./session-context-tab"
export { SortableTab, FileVisual } from "./session-sortable-tab"
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view"
+export { TerminalPanel } from "./session-terminal-panel"
diff --git a/packages/app/src/components/session/session-terminal-panel.tsx b/packages/app/src/components/session/session-terminal-panel.tsx
new file mode 100644
index 00000000000..52748bbb431
--- /dev/null
+++ b/packages/app/src/components/session/session-terminal-panel.tsx
@@ -0,0 +1,131 @@
+import { For, Show, createMemo, type JSX } from "solid-js"
+import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
+import type { DragEvent } from "@thisbeyond/solid-dnd"
+import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
+import { useLayout } from "@/context/layout"
+import { useTerminal, type LocalPTY } from "@/context/terminal"
+import { useCommand } from "@/context/command"
+import { Terminal } from "@/components/terminal"
+import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
+import { SortableTerminalTab } from "./session-sortable-terminal-tab"
+import { createStore } from "solid-js/store"
+
+export interface TerminalPanelProps {
+ position: "bottom" | "right"
+ fullHeight?: boolean
+}
+
+export function TerminalPanel(props: TerminalPanelProps): JSX.Element {
+ const layout = useLayout()
+ const terminal = useTerminal()
+ const command = useCommand()
+
+ const [store, setStore] = createStore({
+ activeDraggable: undefined as string | undefined,
+ })
+
+ const handleDragStart = (event: unknown) => {
+ const id = getDraggableId(event)
+ if (!id) return
+ setStore("activeDraggable", id)
+ }
+
+ const handleDragOver = (event: DragEvent) => {
+ const { draggable, droppable } = event
+ if (draggable && droppable) {
+ const terminals = terminal.all()
+ const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
+ const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
+ if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
+ terminal.move(draggable.id.toString(), toIndex)
+ }
+ }
+ }
+
+ const handleDragEnd = () => {
+ setStore("activeDraggable", undefined)
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ t.id)}>
+ {(pty) => }
+
+
+
+
+
+
+
+
+ {(pty) => (
+
+ terminal.clone(pty.id)} />
+
+ )}
+
+
+
+
+ {(draggedId) => {
+ const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
+ return (
+
+ {(t) => (
+
+ {t().title}
+
+ )}
+
+ )
+ }}
+
+
+
+
+ )
+}
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index bc0d49758bc..7be2299e4bd 100644
--- a/packages/app/src/context/layout.tsx
+++ b/packages/app/src/context/layout.tsx
@@ -65,6 +65,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
terminal: {
opened: false,
height: 280,
+ position: "bottom" as "bottom" | "right",
},
review: {
opened: true,
@@ -181,6 +182,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
resize(height: number) {
setStore("terminal", "height", height)
},
+ position: createMemo(() => store.terminal.position ?? "bottom"),
+ setPosition(position: "bottom" | "right") {
+ setStore("terminal", "position", position)
+ },
+ togglePosition() {
+ setStore("terminal", "position", (p) => (p === "bottom" ? "right" : "bottom"))
+ },
},
review: {
opened: createMemo(() => store.review?.opened ?? true),
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 1b044975209..333b773b9fa 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -21,9 +21,8 @@ import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useSync } from "@/context/sync"
-import { useTerminal, type LocalPTY } from "@/context/terminal"
+import { useTerminal } from "@/context/terminal"
import { useLayout } from "@/context/layout"
-import { Terminal } from "@/components/terminal"
import { checksum, base64Decode } from "@opencode-ai/util/encode"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
@@ -44,8 +43,8 @@ import {
SessionContextTab,
SortableTab,
FileVisual,
- SortableTerminalTab,
NewSessionView,
+ TerminalPanel,
} from "@/components/session"
import { usePlatform } from "@/context/platform"
@@ -248,7 +247,6 @@ export default function Page() {
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
- activeTerminalDraggable: undefined as string | undefined,
expanded: {} as Record,
messageId: undefined as string | undefined,
mobileTab: "session" as "session" | "review",
@@ -376,6 +374,14 @@ export default function Page() {
keybind: "ctrl+shift+`",
onSelect: () => terminal.new(),
},
+ {
+ id: "terminal.position.toggle",
+ title: "Toggle terminal position",
+ description: "Move terminal between bottom and right panel",
+ category: "Terminal",
+ keybind: "ctrl+shift+\\",
+ onSelect: () => layout.terminal.togglePosition(),
+ },
{
id: "steps.toggle",
title: "Toggle steps",
@@ -601,28 +607,6 @@ export default function Page() {
setStore("activeDraggable", undefined)
}
- const handleTerminalDragStart = (event: unknown) => {
- const id = getDraggableId(event)
- if (!id) return
- setStore("activeTerminalDraggable", id)
- }
-
- const handleTerminalDragOver = (event: DragEvent) => {
- const { draggable, droppable } = event
- if (draggable && droppable) {
- const terminals = terminal.all()
- const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
- const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
- if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
- terminal.move(draggable.id.toString(), toIndex)
- }
- }
- }
-
- const handleTerminalDragEnd = () => {
- setStore("activeTerminalDraggable", undefined)
- }
-
const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
const openedTabs = createMemo(() =>
tabs()
@@ -633,10 +617,14 @@ export default function Page() {
const reviewTab = createMemo(() => diffs().length > 0 || tabs().active() === "review")
const mobileReview = createMemo(() => !isDesktop() && diffs().length > 0 && store.mobileTab === "review")
- const showTabs = createMemo(
+ const showReviewTabs = createMemo(
() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0 || contextOpen()),
)
+ const showTabs = createMemo(
+ () => showReviewTabs() || (layout.terminal.position() === "right" && layout.terminal.opened()),
+ )
+
const activeTab = createMemo(() => {
const active = tabs().active()
if (active) return active
@@ -820,11 +808,19 @@ export default function Page() {
>
{(message) => (
@@ -901,7 +897,7 @@ export default function Page() {
direction="horizontal"
size={layout.session.width()}
min={450}
- max={window.innerWidth * 0.45}
+ max={window.innerWidth * 0.75}
onResize={layout.session.resize}
/>
@@ -909,398 +905,346 @@ export default function Page() {
{/* Desktop tabs panel (Review + Context + Files) - hidden on mobile */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Review
-
-
- {info()?.summary?.files ?? 0}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Review
+
+
+ {info()?.summary?.files ?? 0}
+
+
-
-
+
+
+
+
+
+ tabs().close("context")} />
+
+ }
+ hideCloseButton
+ >
+
+
+
+
+ {(tab) => }
+
+
+
+ dialog.show(() => )}
+ />
+
-
+
+
+
+
+
+
+
+
-
- tabs().close("context")} />
-
- }
- hideCloseButton
- >
-
-
-
Context
+
+
+
-
+
-
- {(tab) => }
-
-
-
- dialog.show(() => )}
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {(tab) => {
- let scroll: HTMLDivElement | undefined
- let scrollFrame: number | undefined
- let pending: { x: number; y: number } | undefined
-
- const path = createMemo(() => file.pathFromTab(tab))
- const state = createMemo(() => {
- const p = path()
- if (!p) return
- return file.get(p)
- })
- const contents = createMemo(() => state()?.content?.content ?? "")
- const cacheKey = createMemo(() => checksum(contents()))
- const isImage = createMemo(() => {
- const c = state()?.content
- return (
- c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
- )
- })
- const isSvg = createMemo(() => {
- const c = state()?.content
- return c?.mimeType === "image/svg+xml"
- })
- const svgContent = createMemo(() => {
- if (!isSvg()) return
- const c = state()?.content
- if (!c) return
- if (c.encoding === "base64") return base64Decode(c.content)
- return c.content
- })
- const svgPreviewUrl = createMemo(() => {
- if (!isSvg()) return
- const c = state()?.content
- if (!c) return
- if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
- return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
- })
- const imageDataUrl = createMemo(() => {
- if (!isImage()) return
- const c = state()?.content
- return `data:${c?.mimeType};base64,${c?.content}`
- })
- const selectedLines = createMemo(() => {
- const p = path()
- if (!p) return null
- return file.selectedLines(p) ?? null
- })
- const selection = createMemo(() => {
- const range = selectedLines()
- if (!range) return
- return selectionFromLines(range)
- })
- const selectionLabel = createMemo(() => {
- const sel = selection()
- if (!sel) return
- if (sel.startLine === sel.endLine) return `L${sel.startLine}`
- return `L${sel.startLine}-${sel.endLine}`
- })
-
- const restoreScroll = (retries = 0) => {
- const el = scroll
- if (!el) return
-
- const s = view()?.scroll(tab)
- if (!s) return
-
- // Wait for content to be scrollable - content may not have rendered yet
- if (el.scrollHeight <= el.clientHeight && retries < 10) {
- requestAnimationFrame(() => restoreScroll(retries + 1))
- return
- }
-
- if (el.scrollTop !== s.y) el.scrollTop = s.y
- if (el.scrollLeft !== s.x) el.scrollLeft = s.x
- }
+
+ {(tab) => {
+ let scroll: HTMLDivElement | undefined
+ let scrollFrame: number | undefined
+ let pending: { x: number; y: number } | undefined
+
+ const path = createMemo(() => file.pathFromTab(tab))
+ const state = createMemo(() => {
+ const p = path()
+ if (!p) return
+ return file.get(p)
+ })
+ const contents = createMemo(() => state()?.content?.content ?? "")
+ const cacheKey = createMemo(() => checksum(contents()))
+ const isImage = createMemo(() => {
+ const c = state()?.content
+ return (
+ c?.encoding === "base64" &&
+ c?.mimeType?.startsWith("image/") &&
+ c?.mimeType !== "image/svg+xml"
+ )
+ })
+ const isSvg = createMemo(() => {
+ const c = state()?.content
+ return c?.mimeType === "image/svg+xml"
+ })
+ const svgContent = createMemo(() => {
+ if (!isSvg()) return
+ const c = state()?.content
+ if (!c) return
+ if (c.encoding === "base64") return base64Decode(c.content)
+ return c.content
+ })
+ const svgPreviewUrl = createMemo(() => {
+ if (!isSvg()) return
+ const c = state()?.content
+ if (!c) return
+ if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
+ })
+ const imageDataUrl = createMemo(() => {
+ if (!isImage()) return
+ const c = state()?.content
+ return `data:${c?.mimeType};base64,${c?.content}`
+ })
+ const selectedLines = createMemo(() => {
+ const p = path()
+ if (!p) return null
+ return file.selectedLines(p) ?? null
+ })
+ const selection = createMemo(() => {
+ const range = selectedLines()
+ if (!range) return
+ return selectionFromLines(range)
+ })
+ const selectionLabel = createMemo(() => {
+ const sel = selection()
+ if (!sel) return
+ if (sel.startLine === sel.endLine) return `L${sel.startLine}`
+ return `L${sel.startLine}-${sel.endLine}`
+ })
+
+ const restoreScroll = (retries = 0) => {
+ const el = scroll
+ if (!el) return
+
+ const s = view()?.scroll(tab)
+ if (!s) return
+
+ // Wait for content to be scrollable - content may not have rendered yet
+ if (el.scrollHeight <= el.clientHeight && retries < 10) {
+ requestAnimationFrame(() => restoreScroll(retries + 1))
+ return
+ }
+
+ if (el.scrollTop !== s.y) el.scrollTop = s.y
+ if (el.scrollLeft !== s.x) el.scrollLeft = s.x
+ }
- const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
- pending = {
- x: event.currentTarget.scrollLeft,
- y: event.currentTarget.scrollTop,
- }
- if (scrollFrame !== undefined) return
+ const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
+ pending = {
+ x: event.currentTarget.scrollLeft,
+ y: event.currentTarget.scrollTop,
+ }
+ if (scrollFrame !== undefined) return
- scrollFrame = requestAnimationFrame(() => {
- scrollFrame = undefined
+ scrollFrame = requestAnimationFrame(() => {
+ scrollFrame = undefined
- const next = pending
- pending = undefined
- if (!next) return
+ const next = pending
+ pending = undefined
+ if (!next) return
- view().setScroll(tab, next)
- })
- }
+ view().setScroll(tab, next)
+ })
+ }
- createEffect(
- on(
- () => state()?.loaded,
- (loaded) => {
- if (!loaded) return
- requestAnimationFrame(restoreScroll)
- },
- { defer: true },
- ),
- )
-
- createEffect(
- on(
- () => file.ready(),
- (ready) => {
- if (!ready) return
- requestAnimationFrame(restoreScroll)
- },
- { defer: true },
- ),
- )
-
- createEffect(
- on(
- () => tabs().active() === tab,
- (active) => {
- if (!active) return
- if (!state()?.loaded) return
- requestAnimationFrame(restoreScroll)
- },
- ),
- )
-
- onCleanup(() => {
- if (scrollFrame === undefined) return
- cancelAnimationFrame(scrollFrame)
- })
-
- return (
- {
- scroll = el
- restoreScroll()
- }}
- onScroll={handleScroll}
- >
-
- {(sel) => (
-
-
-
- )}
-
-
-
-
-
})
-
-
-
-
-
{
- const p = path()
- if (!p) return
- file.setSelectedLines(p, range)
- }}
- overflow="scroll"
- class="select-text"
- />
-
-
-
})
+ createEffect(
+ on(
+ () => state()?.loaded,
+ (loaded) => {
+ if (!loaded) return
+ requestAnimationFrame(restoreScroll)
+ },
+ { defer: true },
+ ),
+ )
+
+ createEffect(
+ on(
+ () => file.ready(),
+ (ready) => {
+ if (!ready) return
+ requestAnimationFrame(restoreScroll)
+ },
+ { defer: true },
+ ),
+ )
+
+ createEffect(
+ on(
+ () => tabs().active() === tab,
+ (active) => {
+ if (!active) return
+ if (!state()?.loaded) return
+ requestAnimationFrame(restoreScroll)
+ },
+ ),
+ )
+
+ onCleanup(() => {
+ if (scrollFrame === undefined) return
+ cancelAnimationFrame(scrollFrame)
+ })
+
+ return (
+
{
+ scroll = el
+ restoreScroll()
+ }}
+ onScroll={handleScroll}
+ >
+
+ {(sel) => (
+
+
-
-
-
-
- {
- const p = path()
- if (!p) return
- file.setSelectedLines(p, range)
- }}
- overflow="scroll"
- class="select-text pb-40"
- />
-
-
- Loading...
-
-
- {(err) => {err()}
}
-
-
-
- )
- }}
-
-
-
-
- {(tab) => {
- const path = createMemo(() => file.pathFromTab(tab()))
- return (
-
- {(p) => }
-
- )
- }}
-
-
-
+ )}
+
+
+
+
+
})
+
+
+
+
+
{
+ const p = path()
+ if (!p) return
+ file.setSelectedLines(p, range)
+ }}
+ overflow="scroll"
+ class="select-text"
+ />
+
+
+
})
+
+
+
+
+
+ {
+ const p = path()
+ if (!p) return
+ file.setSelectedLines(p, range)
+ }}
+ overflow="scroll"
+ class="select-text pb-40"
+ />
+
+
+ Loading...
+
+
+ {(err) => {err()}
}
+
+
+
+ )
+ }}
+
+
+
+
+ {(tab) => {
+ const path = createMemo(() => file.pathFromTab(tab()))
+ return (
+
+ {(p) => }
+
+ )
+ }}
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
- t.id)}>
- {(pty) => }
-
-
-
-
-
-
-
-
- {(pty) => (
-
- terminal.clone(pty.id)} />
-
- )}
-
-
-
-
- {(draggedId) => {
- const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
- return (
-
- {(t) => (
-
- {t().title}
-
- )}
-
- )
- }}
-
-
-
-
+
+
)