Skip to content

Commit 7cf8d1c

Browse files
arvsrnBrendonovich
andauthored
feat(app): terminal improvements (#34747)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
1 parent 78bca5c commit 7cf8d1c

10 files changed

Lines changed: 810 additions & 25 deletions

File tree

packages/app/e2e/regression/terminal-hidden.spec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const projectID = "proj_hidden_terminal_regression"
77
const sessionID = "ses_hidden_terminal_regression"
88
const title = "Hidden terminal regression"
99

10-
test("unmounts the terminal renderer while the pane is hidden", async ({ page }) => {
10+
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
1111
await page.setViewportSize({ width: 1400, height: 900 })
1212
await mockOpenCodeServer(page, {
1313
directory,
@@ -64,13 +64,14 @@ test("unmounts the terminal renderer while the pane is hidden", async ({ page })
6464
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
6565

6666
await page.keyboard.press("Control+Backquote")
67-
await expect(panel).toHaveAttribute("aria-hidden", "true")
67+
await expect(panel).toHaveCount(0)
6868
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
6969

7070
await page.setViewportSize({ width: 1200, height: 700 })
7171
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
7272

7373
await page.keyboard.press("Control+Backquote")
74+
await expect(panel).toBeVisible()
7475
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
7576
})
7677

packages/app/e2e/utils/mock-server.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Page, Route } from "@playwright/test"
22

33
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
4-
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp"])
4+
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
55

66
export interface MockServerConfig {
77
provider: unknown
@@ -55,6 +55,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
5555
const path = url.pathname
5656
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
5757
if (path === "/global/health") return json(route, { healthy: true })
58+
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
5859
if (path === "/permission")
5960
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
6061
if (path === "/question")
@@ -65,6 +66,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
6566
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
6667
if (path === "/file/content" && config.fileContent)
6768
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
69+
if (path === "/api/reference")
70+
return json(route, {
71+
location: { directory: config.directory, project: { id: (config.project as { id?: string }).id, directory: config.directory } },
72+
data: [],
73+
})
6874
if (emptyObject.has(path)) return json(route, {})
6975
if (emptyList.has(path)) return json(route, [])
7076
if (path in staticRoutes) return json(route, staticRoutes[path])
@@ -75,6 +81,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
7581
return json(route, session ?? {})
7682
}
7783

84+
const projectMatch = path.match(/^\/project\/([^/]+)$/)
85+
if (projectMatch) return json(route, config.project)
86+
7887
const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/)
7988
if (messageMatch) {
8089
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import type { JSX } from "solid-js"
2+
import { Show, createEffect, onCleanup } from "solid-js"
3+
import { createStore } from "solid-js/store"
4+
import { useSortable } from "@dnd-kit/solid/sortable"
5+
import { IconButton } from "@opencode-ai/ui/icon-button"
6+
import { Tabs } from "@opencode-ai/ui/tabs"
7+
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
8+
import { Icon } from "@opencode-ai/ui/icon"
9+
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
10+
import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title"
11+
import { useTerminal, type LocalPTY } from "@/context/terminal"
12+
import { useLanguage } from "@/context/language"
13+
import { focusTerminalById } from "@/pages/session/helpers"
14+
15+
export function SortableTerminalTabV2(props: {
16+
terminal: LocalPTY
17+
index: () => number
18+
newLayout: boolean
19+
onClose?: () => void
20+
}): JSX.Element {
21+
const terminal = useTerminal()
22+
const language = useLanguage()
23+
const sortable = useSortable({
24+
get id() {
25+
return props.terminal.id
26+
},
27+
get index() {
28+
return props.index()
29+
},
30+
})
31+
const [store, setStore] = createStore({
32+
editing: false,
33+
title: props.terminal.title,
34+
menuOpen: false,
35+
menuPosition: { x: 0, y: 0 },
36+
blurEnabled: false,
37+
})
38+
let input: HTMLInputElement | undefined
39+
let blurFrame: number | undefined
40+
let editRequested = false
41+
42+
const isDefaultTitle = () => {
43+
const number = props.terminal.titleNumber
44+
if (!Number.isFinite(number) || number <= 0) return false
45+
return isDefaultTerminalTitle(props.terminal.title, number)
46+
}
47+
48+
const label = () => {
49+
language.locale()
50+
if (props.terminal.title && !isDefaultTitle()) return props.terminal.title
51+
52+
const number = props.terminal.titleNumber
53+
if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number })
54+
if (props.terminal.title) return props.terminal.title
55+
return language.t("terminal.title")
56+
}
57+
58+
const close = () => {
59+
const count = terminal.all().length
60+
void terminal.close(props.terminal.id)
61+
if (count === 1) {
62+
props.onClose?.()
63+
}
64+
}
65+
66+
const focus = () => {
67+
if (store.editing) return
68+
terminal.open(props.terminal.id)
69+
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
70+
focusTerminalById(props.terminal.id)
71+
}
72+
73+
const edit = (e?: Event) => {
74+
if (e) {
75+
e.stopPropagation()
76+
e.preventDefault()
77+
}
78+
79+
setStore("blurEnabled", false)
80+
setStore("title", props.terminal.title)
81+
setStore("editing", true)
82+
}
83+
84+
const save = () => {
85+
if (!store.blurEnabled) return
86+
87+
const value = store.title.trim()
88+
if (value && value !== props.terminal.title) {
89+
terminal.update({ id: props.terminal.id, title: value })
90+
}
91+
setStore("editing", false)
92+
}
93+
94+
const keydown = (e: KeyboardEvent) => {
95+
if (e.key === "Enter") {
96+
e.preventDefault()
97+
save()
98+
return
99+
}
100+
if (e.key === "Escape") {
101+
e.preventDefault()
102+
setStore("editing", false)
103+
}
104+
}
105+
106+
const menu = (e: MouseEvent) => {
107+
e.preventDefault()
108+
setStore("menuPosition", { x: e.clientX, y: e.clientY })
109+
setStore("menuOpen", true)
110+
}
111+
112+
createEffect(() => {
113+
if (!store.editing) return
114+
if (!input) return
115+
input.focus()
116+
input.select()
117+
if (blurFrame !== undefined) cancelAnimationFrame(blurFrame)
118+
blurFrame = requestAnimationFrame(() => {
119+
blurFrame = undefined
120+
setStore("blurEnabled", true)
121+
})
122+
})
123+
124+
onCleanup(() => {
125+
if (blurFrame === undefined) return
126+
cancelAnimationFrame(blurFrame)
127+
})
128+
129+
return (
130+
<div
131+
ref={sortable.ref}
132+
class="outline-none focus:outline-none focus-visible:outline-none"
133+
classList={{
134+
"h-full flex items-center": props.newLayout,
135+
"h-full": !props.newLayout,
136+
}}
137+
>
138+
<Show
139+
when={props.newLayout}
140+
fallback={
141+
<div class="relative h-full">
142+
<Tabs.Trigger
143+
value={props.terminal.id}
144+
onClick={focus}
145+
onMouseDown={(e) => e.preventDefault()}
146+
onContextMenu={menu}
147+
class="!shadow-none"
148+
classes={{
149+
button: "border-0 outline-none focus:outline-none focus-visible:outline-none !shadow-none !ring-0",
150+
}}
151+
closeButton={
152+
<IconButton
153+
icon="close"
154+
variant="ghost"
155+
onClick={(e) => {
156+
e.stopPropagation()
157+
close()
158+
}}
159+
aria-label={language.t("terminal.close")}
160+
/>
161+
}
162+
>
163+
<span onDblClick={edit} classList={{ invisible: store.editing }}>
164+
{label()}
165+
</span>
166+
</Tabs.Trigger>
167+
<Show when={store.editing}>
168+
<div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
169+
<input
170+
ref={input}
171+
type="text"
172+
value={store.title}
173+
onInput={(e) => setStore("title", e.currentTarget.value)}
174+
onBlur={save}
175+
onKeyDown={keydown}
176+
onMouseDown={(e) => e.stopPropagation()}
177+
class="bg-transparent border-none outline-none text-sm min-w-0 flex-1"
178+
/>
179+
</div>
180+
</Show>
181+
<DropdownMenu open={store.menuOpen} onOpenChange={(open) => setStore("menuOpen", open)}>
182+
<DropdownMenu.Portal>
183+
<DropdownMenu.Content
184+
class="fixed"
185+
style={{
186+
left: `${store.menuPosition.x}px`,
187+
top: `${store.menuPosition.y}px`,
188+
}}
189+
onCloseAutoFocus={(e) => {
190+
if (!editRequested) return
191+
e.preventDefault()
192+
editRequested = false
193+
requestAnimationFrame(() => edit())
194+
}}
195+
>
196+
<DropdownMenu.Item onSelect={() => (editRequested = true)}>
197+
<Icon name="edit" class="w-4 h-4 mr-2" />
198+
{language.t("common.rename")}
199+
</DropdownMenu.Item>
200+
<DropdownMenu.Item onSelect={close}>
201+
<Icon name="close" class="w-4 h-4 mr-2" />
202+
{language.t("common.close")}
203+
</DropdownMenu.Item>
204+
</DropdownMenu.Content>
205+
</DropdownMenu.Portal>
206+
</DropdownMenu>
207+
</div>
208+
}
209+
>
210+
<MenuV2.Context>
211+
<MenuV2.Context.Trigger class="relative" as="div">
212+
<Tabs.Trigger
213+
value={props.terminal.id}
214+
onClick={focus}
215+
closeButton={
216+
<IconButton
217+
icon="close-small"
218+
variant="ghost"
219+
class="h-5 w-5"
220+
onClick={(e) => {
221+
e.stopPropagation()
222+
close()
223+
}}
224+
aria-label={language.t("terminal.close")}
225+
/>
226+
}
227+
hideCloseButton
228+
onMiddleClick={close}
229+
>
230+
<span
231+
class="truncate"
232+
data-slot="terminal-tab-title"
233+
onDblClick={edit}
234+
classList={{ invisible: store.editing }}
235+
>
236+
{label()}
237+
</span>
238+
</Tabs.Trigger>
239+
<Show when={store.editing}>
240+
<div class="absolute inset-0 flex items-center bg-v2-background-bg-layer-01 z-10 pointer-events-auto rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-muted)] px-2">
241+
<input
242+
ref={input}
243+
type="text"
244+
value={store.title}
245+
onInput={(e) => setStore("title", e.currentTarget.value)}
246+
onBlur={save}
247+
onKeyDown={keydown}
248+
onMouseDown={(e) => e.stopPropagation()}
249+
class="bg-transparent border-none outline-none min-w-0 flex-1 p-0 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:440] [font-variation-settings:'slnt'_0] [font-variant-numeric:tabular-nums]"
250+
/>
251+
</div>
252+
</Show>
253+
</MenuV2.Context.Trigger>
254+
<MenuV2.Context.Portal>
255+
<MenuV2.Context.Content
256+
onCloseAutoFocus={(e) => {
257+
if (!editRequested) return
258+
e.preventDefault()
259+
editRequested = false
260+
requestAnimationFrame(() => edit())
261+
}}
262+
>
263+
<MenuV2.Item onSelect={() => (editRequested = true)}>{language.t("common.rename")}</MenuV2.Item>
264+
<MenuV2.Item onSelect={close}>{language.t("common.close")}</MenuV2.Item>
265+
</MenuV2.Context.Content>
266+
</MenuV2.Context.Portal>
267+
</MenuV2.Context>
268+
</Show>
269+
</div>
270+
)
271+
}

packages/app/src/components/titlebar-tab-strip.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ import { createTabPromptState } from "@/context/prompt"
1717
import { base64Encode } from "@opencode-ai/core/util/encode"
1818
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
1919

20-
const sortableTransition = { duration: 0 }
21-
2220
function SessionTabSlot(props: {
2321
tab: SessionTab
2422
id: string

0 commit comments

Comments
 (0)