Skip to content

Commit bd5cd80

Browse files
Maxaubertclaude
andauthored
feat: a clear title bar moves the window with vertical tabs, not the sidebar (#189)
With vertical tabs and the title bar off, a row of the terminal's own ground over the terminal (no line, no name) holds the window buttons and moves the window; the sidebar's empty space no longer does. Claude-Session: https://claude.ai/code/session_017XYVRPzvhLpPCr75uB6uaA Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent a0ce76a commit bd5cd80

6 files changed

Lines changed: 54 additions & 52 deletions

File tree

‎docs/superpowers/specs/2026-10-11-vertical-tabs-design.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ looked at on the way).
5757
bar or some way to drag it"). The e2e asks Windows itself (WM_NCHITTEST) where a press moves the
5858
window.
5959

60+
- **A clear title bar, not the sidebar, moves the window** (owner: "there should be a title bar to
61+
drag from not the sidebar. how about making it transparent"): with vertical tabs and the title bar
62+
off, a 32 px row over the terminal only, in the terminal's own ground with no line and no name,
63+
holds the window buttons and moves the window. The sidebar's empty space no longer does. With the
64+
sidebar collapsed the row spans the window.
65+
6066
## 2. The indicators
6167

6268
- **Vertical tabs have no new look for the indicators** (owner: "vertical tabs shouldnt have a new

‎package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "prism-terminal",
33
"productName": "Prism Terminal",
4-
"version": "0.39.4",
4+
"version": "0.39.5",
55
"description": "A tabbed Windows terminal for AI CLIs.",
66
"main": "./out/main/index.js",
77
"author": "Max",

‎src/renderer/src/App.tsx‎

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,26 @@ export default function App(): JSX.Element {
721721
headStart={bare ? <AppButtons onSettings={onSettings} onHelp={onHelp} /> : undefined}
722722
/>
723723
)}
724+
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
725+
{/* A TITLE BAR YOU CANNOT SEE (owner, 2026-10-11: "there should be a
726+
title bar to drag from not the sidebar. how about making it
727+
transparent"): with the tabs down the side and the title bar off,
728+
the row over the terminal is the terminal's own ground, no line, no
729+
name, and moves the window; the three window buttons (and the update
730+
chip, when there is one) sit at its right. Over the terminal only,
731+
so the sidebar keeps its tabs at the top; with the sidebar collapsed
732+
that is the whole width. */}
733+
{bare && (
734+
<div
735+
data-title-bar="clear"
736+
className="drag flex h-8 shrink-0 items-center justify-end gap-2.5 bg-[var(--p-bg)] pr-1"
737+
>
738+
{chip}
739+
<div data-window-buttons className="no-drag flex items-center gap-1">
740+
<WindowButtons />
741+
</div>
742+
</div>
743+
)}
724744
<div
725745
className="relative min-h-0 min-w-0 flex-1"
726746
data-term-host
@@ -800,20 +820,7 @@ export default function App(): JSX.Element {
800820
)}
801821
</div>
802822
</div>
803-
{/* What is left of the title bar: the window's own three buttons, and
804-
the update chip when there is one, always there and quiet ("either
805-
buttons that show once your cursor is close or always persistent":
806-
always, since a reveal at the edge is the thing that did not hold).
807-
On the terminal's own ground, so a long first line runs under them
808-
rather than through them. */}
809-
{bare && (
810-
<div data-window-buttons className="no-drag absolute right-1 top-1 z-40 flex items-center gap-2.5 rounded-md bg-[var(--p-bg-solid)] px-0.5">
811-
{chip}
812-
<div className="flex items-center gap-1">
813-
<WindowButtons />
814-
</div>
815-
</div>
816-
)}
823+
</div>
817824

818825
{termMenu && activeShell && termMenu.id === activeShell.id && (
819826
<ContextMenu

‎src/renderer/src/components/TabSidebar.tsx‎

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ export function TabSidebar({
105105
onDropFolder: (path: string) => void
106106
onReorder: (id: string, toIndex: number) => void
107107
onOpenRecent: (path: string) => void
108-
/** The title bar is hidden: the sidebar's head is the window's handle. */
108+
/** The title bar is hidden: the sidebar starts at the window's top. */
109109
titleRow: boolean
110110
/** Command help and Settings, at the foot, when there is no title bar. */
111111
headStart?: ReactNode
@@ -248,25 +248,6 @@ export function TabSidebar({
248248
grip.addEventListener('pointercancel', up)
249249
}
250250

251-
// A drag in from Explorer (a folder for a new tab) is not a pointer: no
252-
// pointermove arrives while it is in flight, so the head stops being a
253-
// window handle for the length of it, as the strip does.
254-
const [dragInFlight, setDragInFlight] = useState(false)
255-
useEffect(() => {
256-
const on = (): void => setDragInFlight(true)
257-
const off = (): void => setDragInFlight(false)
258-
window.addEventListener('dragenter', on, true)
259-
window.addEventListener('dragend', off, true)
260-
window.addEventListener('drop', off, true)
261-
window.addEventListener('pointermove', off, true)
262-
return () => {
263-
window.removeEventListener('dragenter', on, true)
264-
window.removeEventListener('dragend', off, true)
265-
window.removeEventListener('drop', off, true)
266-
window.removeEventListener('pointermove', off, true)
267-
}
268-
}, [])
269-
270251
if (!tabs.length) return null
271252
const labels = tabLabels(tabs)
272253
const auxClose = (e: MouseEvent, id: string): void => {
@@ -275,9 +256,6 @@ export function TabSidebar({
275256
onClose(id)
276257
}
277258
}
278-
// Hidden off to the left, it is no handle and no stop for Tab: an element
279-
// moved out of sight can still claim the window's drag region and the keys.
280-
const handle = titleRow && visible && !dragInFlight ? 'drag' : 'no-drag'
281259
return (
282260
<div
283261
data-sidebar-slot
@@ -322,7 +300,7 @@ export function TabSidebar({
322300
// top"). With no title bar, the list's empty space below the tabs
323301
// is what moves the window ("i cant drag the window"); each row is
324302
// no-drag, so a tab still clicks and drags as a tab.
325-
className={`${handle} p-scroll flex min-h-0 flex-1 flex-col gap-px overflow-y-auto pb-2 pt-1.5`}
303+
className="p-scroll flex min-h-0 flex-1 flex-col gap-px overflow-y-auto pb-2 pt-1.5"
326304
>
327305
{tabs.map((t, i) => {
328306
const on = t.id === activeId
@@ -498,10 +476,7 @@ export function TabSidebar({
498476
{/* THE BUTTONS, AT THE FOOT (owner, 2026-10-11): the +, then, with no
499477
title bar, command help and Settings, then the sidebar's own
500478
button. The space between them moves the window too. */}
501-
<div
502-
data-sidebar-foot
503-
className={`${handle} flex h-10 shrink-0 items-center gap-0.5 px-1.5`}
504-
>
479+
<div data-sidebar-foot className="flex h-10 shrink-0 items-center gap-0.5 px-1.5">
505480
<button
506481
className={BTN}
507482
title="New tab (Ctrl+T). Right-click for recent folders"

‎tools/e2e/run.mjs‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2205,8 +2205,10 @@ const scenarios = {
22052205
await page.locator('[data-sidebar-toggle="expand"]').click()
22062206
ok((await page.evaluate(() => localStorage.getItem('prism.window.sidebar'))) === 'open' && (await geo()).host.left > 100, 'its button keeps it open again')
22072207

2208-
// NO TITLE BAR AT ALL (owner, 2026-10-11): command help and Settings in
2209-
// the sidebar's head, the window's three buttons floating at the top right.
2208+
// THE CLEAR TITLE BAR (owner, 2026-10-11: "there should be a title bar
2209+
// to drag from not the sidebar. how about making it transparent"):
2210+
// command help and Settings at the sidebar's foot, a bar of the
2211+
// terminal's own ground over the terminal, the window buttons in it.
22102212
const bar = await gotoPref(page, 'title-bar')
22112213
await bar.locator('[role="switch"]').click()
22122214
await backToFirst(page)
@@ -2217,6 +2219,14 @@ const scenarios = {
22172219
const b = btns?.getBoundingClientRect()
22182220
return {
22192221
rows: document.querySelectorAll('[data-title-bar]').length,
2222+
clear: (() => {
2223+
const c = document.querySelector('[data-title-bar="clear"]')
2224+
const r = c?.getBoundingClientRect()
2225+
return c && r ? { left: Math.round(r.left), top: Math.round(r.top), height: Math.round(r.height), bg: getComputedStyle(c).backgroundColor, holds: c.contains(btns), border: getComputedStyle(c).borderBottomWidth } : null
2226+
})(),
2227+
navRight: Math.round(document.querySelector('[data-tab-sidebar]')?.getBoundingClientRect().right ?? -1),
2228+
hostTop: Math.round(document.querySelector('[data-term-host]')?.getBoundingClientRect().top ?? -1),
2229+
termBg: getComputedStyle(document.querySelector('[data-term-host] .xterm-scrollable-element') ?? document.body).backgroundColor,
22202230
mark: !!document.querySelector('[data-app-mark]'),
22212231
inFoot: !!foot?.querySelector('[data-title-settings]') && !!foot?.querySelector('[data-sidebar-new]') && !!foot?.querySelector('[data-sidebar-toggle]'),
22222232
help: !!foot?.querySelector('[data-title-help]'),
@@ -2229,11 +2239,13 @@ const scenarios = {
22292239
}
22302240
})
22312241
const bare = await top()
2232-
ok(bare.rows === 0 && !bare.mark, `no title bar at all, nor the app's icon (${JSON.stringify(bare)})`)
2242+
ok(bare.rows === 1 && !!bare.clear && !bare.mark, `one bar, the clear one, with no icon or name (${JSON.stringify(bare.clear)})`)
2243+
ok(bare.clear.top === 0 && bare.clear.height >= 28 && Math.abs(bare.clear.left - bare.navRight) <= 1, `over the terminal only, from the sidebar's edge (${JSON.stringify(bare.clear)}, sidebar ends ${bare.navRight})`)
2244+
ok(bare.clear.border === '0px' && bare.hostTop === bare.clear.height, `no line under it, the terminal right below (${bare.clear.border}, terminal at ${bare.hostTop})`)
22332245
ok(bare.navTop === 0, `the sidebar starts at the top of the window (${bare.navTop})`)
22342246
ok(bare.inFoot, "the +, Settings and the sidebar's button are at its foot")
22352247
ok(bare.firstRowTop <= 10, `only tabs at the top (the first row at ${bare.firstRowTop}px)`)
2236-
ok(bare.close && !bare.cog && bare.right <= 12 && bare.top <= 8, `the window's three buttons float at the top right, alone (${JSON.stringify(bare)})`)
2248+
ok(bare.close && !bare.cog && bare.clear.holds && bare.right <= 12 && bare.top <= 8, `the window's three buttons are in it, at the top right, alone (${JSON.stringify(bare)})`)
22372249
// THE DRAG ITSELF, as Windows decides it (owner, 2026-10-11: "i cant drag
22382250
// the window"): 2 is HTCAPTION, a press that moves the window.
22392251
const spots = await page.evaluate(() => {
@@ -2243,20 +2255,22 @@ const scenarios = {
22432255
const foot = document.querySelector('[data-sidebar-foot]').getBoundingClientRect()
22442256
const plus = document.querySelector('[data-sidebar-new]').getBoundingClientRect()
22452257
const row = rows[0].getBoundingClientRect()
2258+
const clear = document.querySelector('[data-title-bar="clear"]').getBoundingClientRect()
22462259
return [
2260+
{ x: clear.left + 80, y: clear.top + clear.height / 2 },
22472261
{ x: nav.left + 60, y: (last.bottom + foot.top) / 2 },
22482262
{ x: plus.right + 12, y: foot.top + foot.height / 2 },
22492263
{ x: row.left + 40, y: row.top + row.height / 2 },
22502264
{ x: nav.right + 200, y: 300 }
22512265
]
22522266
})
22532267
const hit = await hitTest(app, spots)
2254-
ok(hit[0] === 2 && hit[1] === 2, `the sidebar's empty space and its foot move the window (${hit})`)
2255-
ok(hit[2] === 1 && hit[3] === 1, `a tab and the terminal do not (${hit})`)
2268+
ok(hit[0] === 2, `the clear bar moves the window (${hit})`)
2269+
ok(hit[1] === 1 && hit[2] === 1 && hit[3] === 1 && hit[4] === 1, `the sidebar's empty space, its foot, a tab and the terminal do not ("not the sidebar") (${hit})`)
22562270
await page.screenshot({ path: resolve(process.cwd(), '.e2e-shots/side-no-titlebar.png') }).catch(() => {})
22572271
// Settings from the sidebar, and back.
22582272
await (await gotoPref(page, 'title-bar')).locator('[role="switch"]').click()
2259-
ok(!!(await until(() => page.evaluate(() => document.querySelectorAll('[data-title-bar]').length === 1 && !document.querySelector('[data-window-buttons]')), 3000, 50)), 'Show title bar puts the bar back, with the buttons in it')
2273+
ok(!!(await until(() => page.evaluate(() => document.querySelectorAll('[data-title-bar]').length === 1 && !document.querySelector('[data-title-bar="clear"]') && !!document.querySelector('[data-app-mark]')), 3000, 50)), 'Show title bar puts the full bar back')
22602274
await setPref(page, 'tab-layout', 'horizontal')
22612275
ok(!!(await until(() => page.evaluate(() => !!document.querySelector('[data-tab-strip]') && !document.querySelector('[data-tab-sidebar]')), 3000, 50)), 'Horizontal puts the strip back')
22622276
await quietStrip(page)

0 commit comments

Comments
 (0)