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
17 changes: 17 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 @@ -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
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.5",
"version": "0.39.6",
"description": "A tabbed Windows terminal for AI CLIs.",
"main": "./out/main/index.js",
"author": "Max",
Expand Down
6 changes: 3 additions & 3 deletions src/main/edgeWatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})

Expand All @@ -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)
})

Expand Down
6 changes: 3 additions & 3 deletions src/main/edgeWatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand Down
39 changes: 20 additions & 19 deletions src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -722,25 +726,6 @@ export default function App(): JSX.Element {
/>
)}
<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 @@ -821,6 +806,22 @@ export default function App(): JSX.Element {
</div>
</div>
</div>
{/* 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 && (
<HoverTitleBar hold={!!update.state.notice} left={sidebarOpen ? sidebarWidth : 0}>
{chip}
<div data-window-buttons className="no-drag flex items-center gap-1">
<WindowButtons />
</div>
</HoverTitleBar>
)}

{termMenu && activeShell && termMenu.id === activeShell.id && (
<ContextMenu
Expand Down
62 changes: 62 additions & 0 deletions src/renderer/src/components/HoverTitleBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useRef, type JSX, type ReactNode } from 'react'
import { REVEAL, useHoverReveal } from '../lib/hoverReveal'
import { useEdgeNear } from '../lib/edgeNear'

/**
* THE TITLE BAR THAT IS NOT THERE (#162; owner, 2026-10-11): with the tabs in
* the sidebar and the title bar hidden, there is no top row at all. "the
* titlebar should be invisible, and only appear when your cursor is near the
* top since its only needed for the window adjustments and i guess settings
* and command help." So it slides down over the window while the pointer is at
* the top edge, holding only those buttons ("dont show this", the app's name,
* "in the hidden titlebar"), and goes again when the pointer leaves.
*
* `hold` keeps it down while something of its own is open (the update chip's
* notice). Out of sight it is no window handle and no stop for Tab: an element
* moved away can still claim the drag region and the keys.
*/
export function HoverTitleBar({
children,
hold = false,
left = 0
}: {
children: ReactNode
hold?: boolean
/** Where the bar starts: the open sidebar's edge, so it never covers the
* sidebar (owner, 2026-10-11: "dont have the titlebar go over the sidebar
* ... when the sidebar is collapsed then it can go across the whole thing"). */
left?: number
}): JSX.Element {
const bar = useRef<HTMLDivElement>(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 (
<div
ref={bar}
data-title-bar="float"
data-title-shown={shown || undefined}
inert={!shown}
// Slid by its TOP, never a transform: Windows is told where a drag
// region is from the layout, and a transformed bar was reported where
// its slide started, above the window, so it moved nothing (measured
// with WM_NCHITTEST, 2026-10-11).
className={`drag p-styled-font absolute right-0 z-40 flex h-9 items-center justify-end gap-2.5 border-b border-[var(--p-divider)] bg-[var(--p-title)] pl-3 pr-1.5 text-[13px] transition-[top,box-shadow] duration-[200ms] [transition-timing-function:cubic-bezier(.16,1,.3,1)] ${
shown ? 'shadow-[0_10px_30px_-10px_rgba(0,0,0,.8)]' : ''
}`}
style={{ left, top: shown ? 0 : -48 }}
>
{children}
</div>
)
}
14 changes: 11 additions & 3 deletions src/renderer/src/components/TabSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 && <div data-sidebar-grab aria-hidden className="drag h-9 shrink-0" />}
<div
ref={rows}
data-sidebar-rows
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/src/lib/hoverReveal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ describe('the hover reveal (#162)', () => {
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])
})

Expand Down
10 changes: 8 additions & 2 deletions src/renderer/src/lib/hoverReveal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading