diff --git a/docs/superpowers/specs/2026-10-11-vertical-tabs-design.md b/docs/superpowers/specs/2026-10-11-vertical-tabs-design.md
index 4c9cce3..b6d4f6c 100644
--- a/docs/superpowers/specs/2026-10-11-vertical-tabs-design.md
+++ b/docs/superpowers/specs/2026-10-11-vertical-tabs-design.md
@@ -63,6 +63,23 @@ looked at on the way).
holds the window buttons and moves the window. The sidebar's empty space no longer does. With the
sidebar collapsed the row spans the window.
+- **Final, the same night: the pop-out bar, done well** (owner: "a pop out title bar is best actually
+ we just need to do it well ... like zen browser's title bar ... when the sidebar is open the
+ titlebar drops down but only from the sidebar edge and all the way to the right ... make sure the
+ titlebar area is usable for content"; his screen recording of Zen, `Videos/2026-10-11 23-26-24.mp4`).
+ The terminal has the whole height; the bar drops over it within 16 px of the top, from the open
+ sidebar's edge to the right (the whole width when it is collapsed), with only the window buttons,
+ and moves the window while down. The clear bar is gone.
+ - **Why the first pop-out bar could not be dragged** (measured with WM_NCHITTEST): it slid with a CSS
+ transform, and Windows is told where a drag region is from the layout, so the region stayed where
+ the slide started, above the window. It now slides by `top`; the collapsed sidebar slides by
+ `left` for the same reason.
+ - The sidebar shows within 24 px of the left edge (was 8), the bar within 16 px of the top, both
+ after 60 ms (was 90) ("it reacts a bit too late ... could be better if it was like 20px further
+ into the window").
+ - A 36 px strip above the tabs moves the window ("the tabs should start a bit further down on the
+ sidebar so that you can grab that area").
+
## 2. The indicators
- **Vertical tabs have no new look for the indicators** (owner: "vertical tabs shouldnt have a new
diff --git a/package-lock.json b/package-lock.json
index c634d2b..0c7b6e0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "prism-terminal",
- "version": "0.39.5",
+ "version": "0.39.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "prism-terminal",
- "version": "0.39.5",
+ "version": "0.39.6",
"license": "MIT",
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
diff --git a/package.json b/package.json
index 76294ad..2c9cd91 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "prism-terminal",
"productName": "Prism Terminal",
- "version": "0.39.5",
+ "version": "0.39.6",
"description": "A tabbed Windows terminal for AI CLIs.",
"main": "./out/main/index.js",
"author": "Max",
diff --git a/src/main/edgeWatch.test.ts b/src/main/edgeWatch.test.ts
index 01eaf23..d2bdf52 100644
--- a/src/main/edgeWatch.test.ts
+++ b/src/main/edgeWatch.test.ts
@@ -6,9 +6,9 @@ const box = { x: 100, y: 200, width: 800, height: 600 }
describe('the pointer at the window edge (#162)', () => {
it('is at the top inside the content band, and in the resize border above it', () => {
expect(edgeNear({ x: 500, y: 200 }, box).top).toBe(true)
- expect(edgeNear({ x: 500, y: 200 + EDGE.inside - 1 }, box).top).toBe(true)
+ expect(edgeNear({ x: 500, y: 200 + EDGE.top - 1 }, box).top).toBe(true)
expect(edgeNear({ x: 500, y: 200 - EDGE.outside }, box).top).toBe(true)
- expect(edgeNear({ x: 500, y: 200 + EDGE.inside }, box).top).toBe(false)
+ expect(edgeNear({ x: 500, y: 200 + EDGE.top }, box).top).toBe(false)
expect(edgeNear({ x: 500, y: 200 - EDGE.outside - 1 }, box).top).toBe(false)
})
@@ -20,7 +20,7 @@ describe('the pointer at the window edge (#162)', () => {
it('is at the left edge the same way, along the window only', () => {
expect(edgeNear({ x: 100, y: 500 }, box).left).toBe(true)
expect(edgeNear({ x: 100 - EDGE.outside, y: 500 }, box).left).toBe(true)
- expect(edgeNear({ x: 100 + EDGE.inside, y: 500 }, box).left).toBe(false)
+ expect(edgeNear({ x: 100 + EDGE.left, y: 500 }, box).left).toBe(false)
expect(edgeNear({ x: 100, y: 800 + EDGE.outside }, box).left).toBe(false)
})
diff --git a/src/main/edgeWatch.ts b/src/main/edgeWatch.ts
index 95bec0f..a366bcf 100644
--- a/src/main/edgeWatch.ts
+++ b/src/main/edgeWatch.ts
@@ -17,7 +17,7 @@ import type { BrowserWindow, Point, Rectangle } from 'electron'
/** How far inside the content edge counts as "at" it, and how far outside it
* (the resize border's own band), in DIPs. */
-export const EDGE = { inside: 10, outside: 8, everyMs: 60 } as const
+export const EDGE = { top: 16, left: 24, outside: 8, everyMs: 50 } as const
export interface EdgeNear {
top: boolean
@@ -35,8 +35,8 @@ export function edgeNear(cursor: Point, content: Rectangle, zone = EDGE): EdgeNe
const across = x >= -zone.outside && x < content.width + zone.outside
const down = y >= -zone.outside && y < content.height + zone.outside
return {
- top: across && y >= -zone.outside && y < zone.inside,
- left: down && x >= -zone.outside && x < zone.inside,
+ top: across && y >= -zone.outside && y < zone.top,
+ left: down && x >= -zone.outside && x < zone.left,
x,
y
}
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index d7ef133..1e9fc73 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -13,6 +13,8 @@ import TermFind from '@core/renderer/components/TermFind'
import { TabStrip } from './components/TabStrip'
import { TabSidebar } from './components/TabSidebar'
import { useTabLayout } from './lib/tabLayoutPrefs'
+import { useSidebarMode, useSidebarWidth } from './lib/sidebarPrefs'
+import { HoverTitleBar } from './components/HoverTitleBar'
import TitleBar, { AppButtons, TitleButtons, WindowButtons } from './components/TitleBar'
import { useTitleBarMode } from './lib/titleBarPrefs'
import { tabSwitch } from './lib/tabSwitchPrefs'
@@ -165,6 +167,8 @@ export default function App(): JSX.Element {
// the window buttons float at the top right. With no tab open there is no
// sidebar, so the window keeps #91's top row to be moved by.
const bare = vertical && titleBar === 'hidden' && state.tabs.length > 0
+ const sidebarOpen = useSidebarMode() === 'open'
+ const sidebarWidth = useSidebarWidth()
/** Which shell each tab was SPAWNED with. The Shell setting only names what
* the next terminal launches, so a tab opened before it was changed still
* speaks its old language, and the popup preselects by what is running. */
@@ -722,25 +726,6 @@ export default function App(): JSX.Element {
/>
)}
- {/* A TITLE BAR YOU CANNOT SEE (owner, 2026-10-11: "there should be a
- title bar to drag from not the sidebar. how about making it
- transparent"): with the tabs down the side and the title bar off,
- the row over the terminal is the terminal's own ground, no line, no
- name, and moves the window; the three window buttons (and the update
- chip, when there is one) sit at its right. Over the terminal only,
- so the sidebar keeps its tabs at the top; with the sidebar collapsed
- that is the whole width. */}
- {bare && (
-
- )}
+ {/* THE TITLE BAR THAT POPS OUT (owner, 2026-10-11: "a pop out title bar
+ is best actually we just need to do it well ... like zen browser's
+ title bar ... when the sidebar is open the titlebar drops down but
+ only from the sidebar edge and all the way to the right"). With the
+ tabs down the side and the title bar off, the terminal has the whole
+ height ("the titlebar area is usable for content"); the bar drops
+ over it at the top edge with the window's buttons, and moves the
+ window while it is down. */}
+ {bare && (
+
+ {chip}
+
+
+
+
+ )}
{termMenu && activeShell && termMenu.id === activeShell.id && (
(null)
+ // Main's word too: over the resize border of a window that is not maximized
+ // the page hears nothing (edgeWatch.ts).
+ const edge = useEdgeNear(true)
+ const shown = useHoverReveal(
+ true,
+ (e) => {
+ if (e.clientX < left) return false
+ if (e.clientY <= REVEAL.top) return true
+ const box = bar.current?.getBoundingClientRect()
+ return !!box && box.bottom > 0 && e.clientY <= box.bottom
+ },
+ hold || (edge.top && edge.x >= left)
+ )
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/renderer/src/components/TabSidebar.tsx b/src/renderer/src/components/TabSidebar.tsx
index 17349c8..f8b7ab1 100644
--- a/src/renderer/src/components/TabSidebar.tsx
+++ b/src/renderer/src/components/TabSidebar.tsx
@@ -142,7 +142,7 @@ export function TabSidebar({
// Its own height (under a title bar there is one).
const box = el.getBoundingClientRect()
if (e.clientY < box.top || e.clientY > box.bottom) return false
- if (e.clientX <= REVEAL.zone) return true
+ if (e.clientX <= REVEAL.left) return true
// ON IT once shown, measured to where it COMES TO REST, not where the
// slide has got to (owner, 2026-10-11: "the sidebar closes before my
// cursor is near the edge"): a pointer that outran the 260 ms slide was
@@ -287,11 +287,19 @@ export function TabSidebar({
}}
className={`p-styled-font absolute inset-y-0 left-0 flex flex-col border-r border-[var(--p-divider)] bg-[var(--p-tabs)] text-[13px] ${
collapsed
- ? 'transition-[transform,box-shadow] duration-[260ms] [transition-timing-function:cubic-bezier(.16,1,.3,1)]'
+ ? 'transition-[left,box-shadow] duration-[240ms] [transition-timing-function:cubic-bezier(.16,1,.3,1)]'
: ''
} ${collapsed && visible ? 'shadow-[14px_0_40px_-8px_rgba(0,0,0,.8)]' : ''} ${carry?.live ? 'cursor-grabbing [&_*]:cursor-grabbing' : ''}`}
- style={{ width, transform: collapsed && !visible ? 'translateX(calc(-100% - 16px))' : '' }}
+ // Slid by its LEFT, never a transform: Windows is told where a drag
+ // region is from the layout, and a transformed panel's grab strip was
+ // reported where its slide started (measured, WM_NCHITTEST).
+ style={{ width, left: collapsed && !visible ? -(width + 16) : 0 }}
>
+ {/* A STRIP TO GRAB, above the tabs, when there is no title bar (owner,
+ 2026-10-11: "the tabs should start a bit further down on the
+ sidebar so that you can grab that area"). The pop-out bar's height,
+ so the two read as one top edge. */}
+ {titleRow && }
{
it('a pointer moving about inside does not restart the wait', () => {
const { seen, r } = make()
r.feel(true)
- vi.advanceTimersByTime(50)
+ vi.advanceTimersByTime(30)
r.feel(true)
- vi.advanceTimersByTime(REVEAL.openDelay - 50)
+ vi.advanceTimersByTime(REVEAL.openDelay - 30)
expect(seen).toEqual([true])
})
diff --git a/src/renderer/src/lib/hoverReveal.ts b/src/renderer/src/lib/hoverReveal.ts
index ac20442..1341812 100644
--- a/src/renderer/src/lib/hoverReveal.ts
+++ b/src/renderer/src/lib/hoverReveal.ts
@@ -13,9 +13,15 @@ import { useEffect, useRef, useState } from 'react'
* pointer does: a menu it opened is still in use.
*/
export const REVEAL = {
- /** How close to the window's edge the pointer must be, in CSS pixels. */
+ /** How close to the window's edge the pointer must be, in CSS pixels: the
+ * left edge 24 (owner, 2026-10-11: "it reacts a bit too late, my cursor
+ * needs to be essentially at the edge, could be better if it was like
+ * 20px further into the window"), the top 16, where the terminal's first
+ * row also lives. */
zone: 8,
- openDelay: 90,
+ left: 24,
+ top: 16,
+ openDelay: 60,
closeDelay: 340
} as const
diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs
index 482b117..91b5d02 100644
--- a/tools/e2e/run.mjs
+++ b/tools/e2e/run.mjs
@@ -2181,6 +2181,13 @@ const scenarios = {
await page.screenshot({ path: resolve(process.cwd(), '.e2e-shots/side-collapsed-shown.png') }).catch(() => {})
await page.mouse.move(800, 400)
ok(!!(await until(async () => !(await geo()).shown, 3000, 50)), 'and goes when the pointer leaves it')
+ // From 20 px in, not only at the very edge (owner, 2026-10-11: "could be
+ // better if it was like 20px further into the window").
+ await page.mouse.move(60, 300)
+ await page.mouse.move(20, 305)
+ ok(!!(await until(async () => (await geo()).shown, 3000, 20)), 'the pointer 20 px in from the left shows it')
+ await page.mouse.move(800, 400)
+ await until(async () => !(await geo()).shown, 3000, 50)
// A pointer that outruns the slide (owner, 2026-10-11: "the sidebar
// closes before my cursor is near the edge"): straight to near its right
// edge the moment it starts to show, then still.
@@ -2205,68 +2212,73 @@ const scenarios = {
await page.locator('[data-sidebar-toggle="expand"]').click()
ok((await page.evaluate(() => localStorage.getItem('prism.window.sidebar'))) === 'open' && (await geo()).host.left > 100, 'its button keeps it open again')
- // THE CLEAR TITLE BAR (owner, 2026-10-11: "there should be a title bar
- // to drag from not the sidebar. how about making it transparent"):
- // command help and Settings at the sidebar's foot, a bar of the
- // terminal's own ground over the terminal, the window buttons in it.
+ // THE POP-OUT TITLE BAR (owner, 2026-10-11: "a pop out title bar is
+ // best actually we just need to do it well ... like zen browser's"):
+ // the terminal has the whole height; the bar drops at the top edge from
+ // the sidebar's edge to the right, with the window's buttons, and moves
+ // the window while it is down. The sidebar keeps a strip to grab above
+ // its tabs, and its buttons at its foot.
const bar = await gotoPref(page, 'title-bar')
await bar.locator('[role="switch"]').click()
await backToFirst(page)
+ await page.mouse.move(600, 400)
+ await sleep(700)
const top = () =>
page.evaluate(() => {
+ const f = document.querySelector('[data-title-bar="float"]')
+ const r = f?.getBoundingClientRect()
const foot = document.querySelector('[data-sidebar-foot]')
const btns = document.querySelector('[data-window-buttons]')
- const b = btns?.getBoundingClientRect()
return {
rows: document.querySelectorAll('[data-title-bar]').length,
- clear: (() => {
- const c = document.querySelector('[data-title-bar="clear"]')
- const r = c?.getBoundingClientRect()
- 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
- })(),
- navRight: Math.round(document.querySelector('[data-tab-sidebar]')?.getBoundingClientRect().right ?? -1),
- hostTop: Math.round(document.querySelector('[data-term-host]')?.getBoundingClientRect().top ?? -1),
- termBg: getComputedStyle(document.querySelector('[data-term-host] .xterm-scrollable-element') ?? document.body).backgroundColor,
- mark: !!document.querySelector('[data-app-mark]'),
+ shown: f?.hasAttribute('data-title-shown') ?? false,
+ bar: r ? { left: Math.round(r.left), top: Math.round(r.top), bottom: Math.round(r.bottom), right: Math.round(innerWidth - r.right) } : null,
+ buttons: !!btns && !!f?.contains(btns) && !!btns.querySelector('[data-window-close]'),
+ name: (f?.textContent ?? '').includes('Prism Terminal') || !!f?.querySelector('[data-app-mark]') || !!f?.querySelector('[data-title-settings]'),
inFoot: !!foot?.querySelector('[data-title-settings]') && !!foot?.querySelector('[data-sidebar-new]') && !!foot?.querySelector('[data-sidebar-toggle]'),
- help: !!foot?.querySelector('[data-title-help]'),
- close: !!btns?.querySelector('[data-window-close]') && !!btns?.querySelector('[aria-label="Minimize"]') && !!btns?.querySelector('[aria-label="Maximize"]'),
- cog: !!btns?.querySelector('[data-title-settings]'),
- right: b ? Math.round(innerWidth - b.right) : null,
- top: b ? Math.round(b.top) : null,
navTop: Math.round(document.querySelector('[data-tab-sidebar]')?.getBoundingClientRect().top ?? -1),
+ navRight: Math.round(document.querySelector('[data-tab-sidebar]')?.getBoundingClientRect().right ?? -1),
+ hostTop: Math.round(document.querySelector('[data-term-host]')?.getBoundingClientRect().top ?? -1),
firstRowTop: Math.round(document.querySelector('[data-tab-sidebar] [data-tab]')?.getBoundingClientRect().top ?? -1)
}
})
- const bare = await top()
- ok(bare.rows === 1 && !!bare.clear && !bare.mark, `one bar, the clear one, with no icon or name (${JSON.stringify(bare.clear)})`)
- 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})`)
- 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})`)
- ok(bare.navTop === 0, `the sidebar starts at the top of the window (${bare.navTop})`)
- ok(bare.inFoot, "the +, Settings and the sidebar's button are at its foot")
- ok(bare.firstRowTop <= 10, `only tabs at the top (the first row at ${bare.firstRowTop}px)`)
- 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)})`)
- // THE DRAG ITSELF, as Windows decides it (owner, 2026-10-11: "i cant drag
- // the window"): 2 is HTCAPTION, a press that moves the window.
+ const up = await top()
+ ok(up.rows === 1 && !up.shown && up.bar.bottom <= 0, `the bar is up out of sight (${JSON.stringify(up.bar)})`)
+ ok(up.hostTop === 0, `and the terminal has the whole height ("usable for content") (${up.hostTop})`)
+ ok(up.navTop === 0 && up.firstRowTop >= 34, `the sidebar starts at the top, its tabs a strip lower (${up.firstRowTop}px)`)
+ ok(up.inFoot, "the +, Settings and the sidebar's button are at its foot")
+ // Within the top 16 px, not only at the very edge.
+ await page.mouse.move(600, 30)
+ await page.mouse.move(610, 12)
+ ok(!!(await until(async () => (await top()).shown, 3000, 50)), 'the pointer 12 px from the top brings the bar down')
+ await sleep(400)
+ const down = await top()
+ ok(down.bar.top === 0 && Math.abs(down.bar.left - down.navRight) <= 1 && down.bar.right === 0, `from the sidebar's edge to the right, not over it (${JSON.stringify(down.bar)}, sidebar ends ${down.navRight})`)
+ ok(down.buttons && !down.name, "with the window's buttons and nothing else")
+ ok((await top()).hostTop === 0, 'over the terminal, which keeps its size')
+ // THE DRAG ITSELF, as Windows decides it: 2 (HTCAPTION) moves the window.
const spots = await page.evaluate(() => {
- const rows = [...document.querySelectorAll('[data-tab-sidebar] [data-tab]')]
- const last = rows[rows.length - 1].getBoundingClientRect()
+ const f = document.querySelector('[data-title-bar="float"]').getBoundingClientRect()
+ const grab = document.querySelector('[data-sidebar-grab]').getBoundingClientRect()
+ const row = document.querySelector('[data-tab-sidebar] [data-tab]').getBoundingClientRect()
const nav = document.querySelector('[data-tab-sidebar]').getBoundingClientRect()
- const foot = document.querySelector('[data-sidebar-foot]').getBoundingClientRect()
- const plus = document.querySelector('[data-sidebar-new]').getBoundingClientRect()
- const row = rows[0].getBoundingClientRect()
- const clear = document.querySelector('[data-title-bar="clear"]').getBoundingClientRect()
return [
- { x: clear.left + 80, y: clear.top + clear.height / 2 },
- { x: nav.left + 60, y: (last.bottom + foot.top) / 2 },
- { x: plus.right + 12, y: foot.top + foot.height / 2 },
+ { x: f.left + 120, y: f.top + f.height / 2 },
+ { x: grab.left + 60, y: grab.top + grab.height / 2 },
{ x: row.left + 40, y: row.top + row.height / 2 },
{ x: nav.right + 200, y: 300 }
]
})
const hit = await hitTest(app, spots)
- ok(hit[0] === 2, `the clear bar moves the window (${hit})`)
- 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})`)
+ ok(hit[0] === 2, `the bar, while down, moves the window (${hit})`)
+ ok(hit[1] === 2, `the strip above the tabs moves the window (${hit})`)
+ ok(hit[2] === 1 && hit[3] === 1, `a tab and the terminal do not (${hit})`)
+ await page.screenshot({ path: resolve(process.cwd(), '.e2e-shots/side-titlebar-shown.png') }).catch(() => {})
+ await page.mouse.move(600, 400)
+ ok(!!(await until(async () => !(await top()).shown, 3000, 50)), 'it goes up again when the pointer leaves')
+ await sleep(400)
+ const gone = await hitTest(app, [spots[0]])
+ ok(gone[0] === 1, `and once up, that row is the terminal's again (${gone})`)
await page.screenshot({ path: resolve(process.cwd(), '.e2e-shots/side-no-titlebar.png') }).catch(() => {})
// Settings from the sidebar, and back.
await (await gotoPref(page, 'title-bar')).locator('[role="switch"]').click()