Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/superpowers/specs/2026-10-11-vertical-tabs-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ looked at on the way).
bar or some way to drag it"). The e2e asks Windows itself (WM_NCHITTEST) where a press moves the
window.

- **A clear title bar, not the sidebar, moves the window** (owner: "there should be a title bar to
drag from not the sidebar. how about making it transparent"): with vertical tabs and the title bar
off, a 32 px row over the terminal only, in the terminal's own ground with no line and no name,
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.

## 2. The indicators

- **Vertical tabs have no new look for the indicators** (owner: "vertical tabs shouldnt have a new
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "prism-terminal",
"productName": "Prism Terminal",
"version": "0.39.4",
"version": "0.39.5",
"description": "A tabbed Windows terminal for AI CLIs.",
"main": "./out/main/index.js",
"author": "Max",
Expand Down
35 changes: 21 additions & 14 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,26 @@ export default function App(): JSX.Element {
headStart={bare ? <AppButtons onSettings={onSettings} onHelp={onHelp} /> : undefined}
/>
)}
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
{/* 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 && (
<div
data-title-bar="clear"
className="drag flex h-8 shrink-0 items-center justify-end gap-2.5 bg-[var(--p-bg)] pr-1"
>
{chip}
<div data-window-buttons className="no-drag flex items-center gap-1">
<WindowButtons />
</div>
</div>
)}
<div
className="relative min-h-0 min-w-0 flex-1"
data-term-host
Expand Down Expand Up @@ -800,20 +820,7 @@ export default function App(): JSX.Element {
)}
</div>
</div>
{/* What is left of the title bar: the window's own three buttons, and
the update chip when there is one, always there and quiet ("either
buttons that show once your cursor is close or always persistent":
always, since a reveal at the edge is the thing that did not hold).
On the terminal's own ground, so a long first line runs under them
rather than through them. */}
{bare && (
<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">
{chip}
<div className="flex items-center gap-1">
<WindowButtons />
</div>
</div>
)}
</div>

{termMenu && activeShell && termMenu.id === activeShell.id && (
<ContextMenu
Expand Down
31 changes: 3 additions & 28 deletions src/renderer/src/components/TabSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export function TabSidebar({
onDropFolder: (path: string) => void
onReorder: (id: string, toIndex: number) => void
onOpenRecent: (path: string) => void
/** The title bar is hidden: the sidebar's head is the window's handle. */
/** The title bar is hidden: the sidebar starts at the window's top. */
titleRow: boolean
/** Command help and Settings, at the foot, when there is no title bar. */
headStart?: ReactNode
Expand Down Expand Up @@ -248,25 +248,6 @@ export function TabSidebar({
grip.addEventListener('pointercancel', up)
}

// A drag in from Explorer (a folder for a new tab) is not a pointer: no
// pointermove arrives while it is in flight, so the head stops being a
// window handle for the length of it, as the strip does.
const [dragInFlight, setDragInFlight] = useState(false)
useEffect(() => {
const on = (): void => setDragInFlight(true)
const off = (): void => setDragInFlight(false)
window.addEventListener('dragenter', on, true)
window.addEventListener('dragend', off, true)
window.addEventListener('drop', off, true)
window.addEventListener('pointermove', off, true)
return () => {
window.removeEventListener('dragenter', on, true)
window.removeEventListener('dragend', off, true)
window.removeEventListener('drop', off, true)
window.removeEventListener('pointermove', off, true)
}
}, [])

if (!tabs.length) return null
const labels = tabLabels(tabs)
const auxClose = (e: MouseEvent, id: string): void => {
Expand All @@ -275,9 +256,6 @@ export function TabSidebar({
onClose(id)
}
}
// Hidden off to the left, it is no handle and no stop for Tab: an element
// moved out of sight can still claim the window's drag region and the keys.
const handle = titleRow && visible && !dragInFlight ? 'drag' : 'no-drag'
return (
<div
data-sidebar-slot
Expand Down Expand Up @@ -322,7 +300,7 @@ export function TabSidebar({
// top"). With no title bar, the list's empty space below the tabs
// is what moves the window ("i cant drag the window"); each row is
// no-drag, so a tab still clicks and drags as a tab.
className={`${handle} p-scroll flex min-h-0 flex-1 flex-col gap-px overflow-y-auto pb-2 pt-1.5`}
className="p-scroll flex min-h-0 flex-1 flex-col gap-px overflow-y-auto pb-2 pt-1.5"
>
{tabs.map((t, i) => {
const on = t.id === activeId
Expand Down Expand Up @@ -498,10 +476,7 @@ export function TabSidebar({
{/* THE BUTTONS, AT THE FOOT (owner, 2026-10-11): the +, then, with no
title bar, command help and Settings, then the sidebar's own
button. The space between them moves the window too. */}
<div
data-sidebar-foot
className={`${handle} flex h-10 shrink-0 items-center gap-0.5 px-1.5`}
>
<div data-sidebar-foot className="flex h-10 shrink-0 items-center gap-0.5 px-1.5">
<button
className={BTN}
title="New tab (Ctrl+T). Right-click for recent folders"
Expand Down
28 changes: 21 additions & 7 deletions tools/e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2205,8 +2205,10 @@ 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')

// NO TITLE BAR AT ALL (owner, 2026-10-11): command help and Settings in
// the sidebar's head, the window's three buttons floating at the top right.
// 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.
const bar = await gotoPref(page, 'title-bar')
await bar.locator('[role="switch"]').click()
await backToFirst(page)
Expand All @@ -2217,6 +2219,14 @@ const scenarios = {
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]'),
inFoot: !!foot?.querySelector('[data-title-settings]') && !!foot?.querySelector('[data-sidebar-new]') && !!foot?.querySelector('[data-sidebar-toggle]'),
help: !!foot?.querySelector('[data-title-help]'),
Expand All @@ -2229,11 +2239,13 @@ const scenarios = {
}
})
const bare = await top()
ok(bare.rows === 0 && !bare.mark, `no title bar at all, nor the app's icon (${JSON.stringify(bare)})`)
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.right <= 12 && bare.top <= 8, `the window's three buttons float at the top right, alone (${JSON.stringify(bare)})`)
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 spots = await page.evaluate(() => {
Expand All @@ -2243,20 +2255,22 @@ const scenarios = {
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: 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 && hit[1] === 2, `the sidebar's empty space and its foot move the window (${hit})`)
ok(hit[2] === 1 && hit[3] === 1, `a tab and the terminal do not (${hit})`)
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})`)
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()
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')
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')
await setPref(page, 'tab-layout', 'horizontal')
ok(!!(await until(() => page.evaluate(() => !!document.querySelector('[data-tab-strip]') && !document.querySelector('[data-tab-sidebar]')), 3000, 50)), 'Horizontal puts the strip back')
await quietStrip(page)
Expand Down
Loading