From d763c11a6d5bc57869f11c87f5a293f61e427e0a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 4 Dec 2025 15:37:29 -0600 Subject: feat(desktop): terminal pane (#5081) Co-authored-by: Github Action Co-authored-by: Dax Raad --- packages/desktop/src/pages/layout.tsx | 114 +++++- packages/desktop/src/pages/session.tsx | 649 +++++++++++++++++++-------------- 2 files changed, 483 insertions(+), 280 deletions(-) (limited to 'packages/desktop/src/pages') diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 15180c885..106a2e733 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -1,9 +1,9 @@ import { createMemo, For, ParentProps, Show } from "solid-js" import { DateTime } from "luxon" -import { A, useParams } from "@solidjs/router" +import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" -import { base64Encode } from "@/utils" +import { base64Decode, base64Encode } from "@/utils" import { Mark } from "@opencode-ai/ui/logo" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" @@ -12,11 +12,21 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { getFilename } from "@opencode-ai/util/path" +import { Select } from "@opencode-ai/ui/select" +import { Session } from "@opencode-ai/sdk/client" export default function Layout(props: ParentProps) { + const navigate = useNavigate() const params = useParams() const globalSync = useGlobalSync() const layout = useLayout() + const currentDirectory = createMemo(() => base64Decode(params.dir ?? "")) + const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? []) + const currentSession = createMemo(() => sessions().find((s) => s.id === params.id) ?? sessions().at(0)) + + function navigateToSession(session: Session | undefined) { + navigate(`/${params.dir}/session/${session?.id}`) + } const handleOpenProject = async () => { // layout.projects.open(dir.) @@ -24,7 +34,7 @@ export default function Layout(props: ParentProps) { return (
-
+
+
+
+
+ x.title} + value={(x) => x.id} + onSelect={navigateToSession} + class="text-14-regular text-text-base max-w-3xs" + variant="ghost" + /> +
+ +
+
+ + Toggle terminal + Ctrl ` +
+ } + > + + +
+
+ +
{ + e.preventDefault() + const startX = e.clientX + const startWidth = layout.sidebar.width() + const maxWidth = window.innerWidth * 0.3 + const minWidth = 150 + const collapseThreshold = 80 + let currentWidth = startWidth + + document.body.style.userSelect = "none" + document.body.style.overflow = "hidden" + + const onMouseMove = (moveEvent: MouseEvent) => { + const deltaX = moveEvent.clientX - startX + currentWidth = startWidth + deltaX + const clampedWidth = Math.min(maxWidth, Math.max(minWidth, currentWidth)) + layout.sidebar.resize(clampedWidth) + } + + const onMouseUp = () => { + document.body.style.userSelect = "" + document.body.style.overflow = "" + document.removeEventListener("mousemove", onMouseMove) + document.removeEventListener("mouseup", onMouseUp) + + if (currentWidth < collapseThreshold) { + layout.sidebar.close() + } + } + + document.addEventListener("mousemove", onMouseMove) + document.addEventListener("mouseup", onMouseUp) + }} + /> +
-
{props.children}
+
{props.children}
) diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx index d6ce62b70..773625334 100644 --- a/packages/desktop/src/pages/session.tsx +++ b/packages/desktop/src/pages/session.tsx @@ -1,4 +1,4 @@ -import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo } from "solid-js" +import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo, createEffect } from "solid-js" import { useLocal, type LocalFile } from "@/context/local" import { createStore } from "solid-js/store" import { PromptInput } from "@/components/prompt-input" @@ -31,6 +31,7 @@ import { useSession } from "@/context/session" import { useLayout } from "@/context/layout" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { Diff } from "@opencode-ai/ui/diff" +import { Terminal } from "@/components/terminal" export default function Page() { const layout = useLayout() @@ -54,6 +55,14 @@ export default function Page() { document.removeEventListener("keydown", handleKeyDown) }) + createEffect(() => { + if (layout.terminal.opened()) { + if (session.terminal.all().length === 0) { + session.terminal.new() + } + } + }) + const handleKeyDown = (event: KeyboardEvent) => { if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") { event.preventDefault() @@ -73,6 +82,16 @@ export default function Page() { document.documentElement.setAttribute("data-theme", nextTheme) return } + if (event.ctrlKey && event.key.toLowerCase() === "`") { + event.preventDefault() + layout.terminal.toggle() + return + } + + // @ts-expect-error + if (document.activeElement?.dataset?.component === "terminal") { + return + } const focused = document.activeElement === inputRef if (focused) { @@ -141,7 +160,7 @@ export default function Page() { const handleDragOver = (event: DragEvent) => { const { draggable, droppable } = event if (draggable && droppable) { - const currentTabs = session.layout.tabs.opened + const currentTabs = session.layout.tabs.all const fromIndex = currentTabs?.indexOf(draggable.id.toString()) const toIndex = currentTabs?.indexOf(droppable.id.toString()) if (fromIndex !== toIndex && toIndex !== undefined) { @@ -259,317 +278,397 @@ export default function Page() { const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length) return ( -
- - - - -
- - -
-
Session
- +
+ + + + +
+ + +
+
Session
+ + +
{session.usage.context() ?? 0}%
+
+
+
+ + + } > - -
{session.usage.context() ?? 0}%
- -
- - - - } - > -
- - - -
-
Review
- -
- {session.info()?.summary?.files ?? 0} -
+
+ + +
+
Review
+ +
+ {session.info()?.summary?.files ?? 0} +
+
+
-
- - - - - {(tab) => } - - -
- - setStore("fileSelectOpen", true)} - /> - -
- -
- -
+ + + + + {(tab) => ( + + )} + + +
+ + setStore("fileSelectOpen", true)} + /> + +
+ +
+
- - -
- - 1 - ? "pr-6 pl-18" - : "px-6"), - }} - diffComponent={Diff} - /> -
-
- -
-
New session
-
- -
- {getDirectory(sync.data.path.directory)} - {getFilename(sync.data.path.directory)} -
+
+ + +
+ + 1 + ? "pr-6 pl-18" + : "px-6"), + }} + diffComponent={Diff} + />
-
- -
- Last modified  - - {DateTime.fromMillis(sync.data.project.time.created).toRelative()} - + + +
+
New session
+
+ +
+ {getDirectory(sync.data.path.directory)} + {getFilename(sync.data.path.directory)} +
+
+
+ +
+ Last modified  + + {DateTime.fromMillis(sync.data.project.time.created).toRelative()} + +
+
+ +
+
+ { + inputRef = el + }} + />
- - -
-
- { - inputRef = el +
+
+ +
+ + { + layout.review.tab() + session.layout.setActiveTab("review") + }} + /> + + } />
-
+
- + + +
- { - layout.review.tab() - session.layout.setActiveTab("review") - }} - /> - - } + split />
-
-
- - - + + + + {(tab) => { + const [file] = createResource( + () => tab, + async (tab) => { + if (tab.startsWith("file://")) { + return local.file.node(tab.replace("file://", "")) + } + return undefined + }, + ) + return ( + + + + {(f) => ( + + )} + + + + ) + }} + + + + + {(draggedFile) => { + const [file] = createResource( + () => draggedFile(), + async (tab) => { + if (tab.startsWith("file://")) { + return local.file.node(tab.replace("file://", "")) + } + return undefined + }, + ) + return ( +
+ {(f) => } +
+ ) + }} +
+
+ + +
+ { + inputRef = el + }} + /> +
+
+ + } + > +
    + + {(path) => ( +
  • + +
  • + )} +
    +
+ +
+ + x} + onOpenChange={(open) => setStore("fileSelectOpen", open)} + onSelect={(x) => { + if (x) { + local.file.open(x) + return session.layout.openTab("file://" + x) + } + return undefined + }} + > + {(i) => (
- -
- -
- - {(tab) => { - const [file] = createResource( - () => tab, - async (tab) => { - if (tab.startsWith("file://")) { - return local.file.node(tab.replace("file://", "")) - } - return undefined - }, - ) - return ( - - - - {(f) => ( - - )} - - - - ) - }} - - - - - {(draggedFile) => { - const [file] = createResource( - () => draggedFile(), - async (tab) => { - if (tab.startsWith("file://")) { - return local.file.node(tab.replace("file://", "")) - } - return undefined - }, - ) - return ( -
- {(f) => } +
+ +
+ + {getDirectory(i)} + + {getFilename(i)} +
- ) - }} - - - - -
- { - inputRef = el +
+
+ )} + +
+
+ +
+
{ + e.preventDefault() + const startY = e.clientY + const startHeight = layout.terminal.height() + const maxHeight = window.innerHeight * 0.6 + const minHeight = 100 + const collapseThreshold = 50 + let currentHeight = startHeight + + document.body.style.userSelect = "none" + document.body.style.overflow = "hidden" + + const onMouseMove = (moveEvent: MouseEvent) => { + const deltaY = startY - moveEvent.clientY + currentHeight = startHeight + deltaY + const clampedHeight = Math.min(maxHeight, Math.max(minHeight, currentHeight)) + layout.terminal.resize(clampedHeight) + } + + const onMouseUp = () => { + document.body.style.userSelect = "" + document.body.style.overflow = "" + document.removeEventListener("mousemove", onMouseMove) + document.removeEventListener("mouseup", onMouseUp) + + if (currentHeight < collapseThreshold) { + layout.terminal.close() + } + } + + document.addEventListener("mousemove", onMouseMove) + document.addEventListener("mouseup", onMouseUp) }} /> -
- - - }> -
    - - {(path) => ( -
  • - -
  • + {terminal.title} + + )} +
    +
    + + + +
    + + + {(terminal) => ( + + + )} -
- -
- - x} - onOpenChange={(open) => setStore("fileSelectOpen", open)} - onSelect={(x) => { - if (x) { - local.file.open(x) - return session.layout.openTab("file://" + x) - } - return undefined - }} - > - {(i) => ( -
-
- -
- - {getDirectory(i)} - - {getFilename(i)} -
-
-
-
- )} -
+ +
) -- cgit v1.2.3