diff --git a/.changeset/header-divider.md b/.changeset/header-divider.md
new file mode 100644
index 00000000..6868a3a1
--- /dev/null
+++ b/.changeset/header-divider.md
@@ -0,0 +1,5 @@
+---
+'aicodeman': minor
+---
+
+Drag the line between the session tabs and the header toolbar to resize the toolbar. Dragging it right narrows the toolbar, which then wraps onto a second line instead of squeezing out the tabs, and the usage readings shed their labels, then their provider names, then (in the Compact style) their percentages as it narrows; double-click or Home resets it. Arrow keys resize it from the keyboard, and the width is remembered on the device.
diff --git a/config/test-suites.ts b/config/test-suites.ts
index ae866007..864d1027 100644
--- a/config/test-suites.ts
+++ b/config/test-suites.ts
@@ -26,6 +26,7 @@ export const BROWSER_TEST_GLOBS = [
'test/tab-rail-search.browser.test.ts',
'test/tab-focus.browser.test.ts',
'test/session-sidebar-ux.browser.test.ts',
+ 'test/header-divider.browser.test.ts',
'test/session-options-responsive.browser.test.ts',
'test/inline-rename.test.ts',
'test/opencode-resize.test.ts',
diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md
index b40ab49b..db23eb42 100644
--- a/docs/wiki/The-Dashboard.md
+++ b/docs/wiki/The-Dashboard.md
@@ -178,6 +178,12 @@ The connection readout, CPU, MEM and the plan usage windows can be drawn three w
Hiding System Stats or Plan Usage still hides them in every style.
+The line between the session tabs and the toolbar is a handle: drag it right to narrow the toolbar
+(it wraps onto a second line and the usage readings shed their labels, then their provider names,
+then, in the Compact style, their percentages), left to widen it. Arrow keys resize it, and
+double-click or Home resets it. It stops at the widest reading, the width is kept per device, and it
+is not shown on phones, in the sidebar layout, in the vertical rail or in a solo window.
+
New header controls never appear on phones. Phone layout is deliberately minimal and is
covered in [Mobile Guide](Mobile-Guide).
diff --git a/scripts/build.mjs b/scripts/build.mjs
index 65db3881..d949fcad 100644
--- a/scripts/build.mjs
+++ b/scripts/build.mjs
@@ -120,6 +120,7 @@ run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --m
run('minify tab-layout-browser.js', 'npx esbuild dist/web/public/tab-layout-browser.js --minify --outfile=dist/web/public/tab-layout-browser.js --allow-overwrite');
run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite');
run('minify tab-rail-resize.js', 'npx esbuild dist/web/public/tab-rail-resize.js --minify --outfile=dist/web/public/tab-rail-resize.js --allow-overwrite');
+run('minify header-divider.js', 'npx esbuild dist/web/public/header-divider.js --minify --outfile=dist/web/public/header-divider.js --allow-overwrite');
run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite');
run('minify respawn-ui.js', 'npx esbuild dist/web/public/respawn-ui.js --minify --outfile=dist/web/public/respawn-ui.js --allow-overwrite');
run('minify ralph-panel.js', 'npx esbuild dist/web/public/ralph-panel.js --minify --outfile=dist/web/public/ralph-panel.js --allow-overwrite');
@@ -149,6 +150,7 @@ console.log('\n[build] content-hash cache busting');
'tab-layout-browser.js',
'app.js',
'tab-rail-resize.js',
+ 'header-divider.js',
'terminal-ui.js',
'respawn-ui.js',
'ralph-panel.js',
diff --git a/src/web/public/app.js b/src/web/public/app.js
index 766228d1..22a537c0 100644
--- a/src/web/public/app.js
+++ b/src/web/public/app.js
@@ -3717,7 +3717,9 @@ class CodemanApp {
seg('mo', pct(usage?.monthly), false),
].filter(Boolean);
if (!windows.length) return '';
- const label = labelled ? `${provider}` : '';
+ // data-short is what the narrowest header densities show instead of the name (header-divider.js).
+ const short = { Claude: 'CL', Codex: 'CX', Copilot: 'CP' }[provider] ?? provider.slice(0, 2).toUpperCase();
+ const label = labelled ? `${provider}` : '';
return `${label}${windows.join('ยท')}`;
};
const rows = [row('Claude', data, true), row('Codex', data.codex, false), row('Copilot', data.copilot, false)].filter(
diff --git a/src/web/public/header-divider.js b/src/web/public/header-divider.js
new file mode 100644
index 00000000..0309bf62
--- /dev/null
+++ b/src/web/public/header-divider.js
@@ -0,0 +1,209 @@
+/**
+ * @fileoverview A draggable divider on the left edge of the header's right-hand toolbar.
+ *
+ * Dragging it left gives the toolbar (stats, plan usage, buttons) more room; dragging it right narrows the
+ * toolbar, which then wraps onto a second line instead of squeezing the session tabs out. The width is a
+ * device-local convenience kept in localStorage; double-click or Home resets it. The handle is absolutely
+ * positioned inside #headerRight, so it takes no layout room and is simply hidden where the tabs are not in
+ * the header (sidebar, vertical rail, phones).
+ *
+ * @loadorder after app.js; self-contained, no CodemanApp dependency.
+ */
+(function () {
+ const STORAGE_KEY = 'codeman-header-right-width';
+ const MIN_WIDTH = 160;
+ const MIN_TABS_ROOM = 240;
+ const KEY_STEP = 16;
+
+ /** Keep a requested toolbar width between the minimum and what leaves the tabs some room. */
+ function clampWidth(width, headerWidth, brandWidth) {
+ const max = Math.max(MIN_WIDTH, headerWidth - brandWidth - MIN_TABS_ROOM);
+ return Math.round(Math.min(max, Math.max(MIN_WIDTH, width)));
+ }
+
+ /**
+ * How much of the usage readings to show for a toolbar this wide. The narrower the toolbar, the more of
+ * the labels go: window labels first, then provider names (shortened), then, in the Compact style, the
+ * percentages (the rings stay, and the chip's tooltip keeps the numbers).
+ */
+ function densityFor(width) {
+ if (width >= 560) return '';
+ if (width >= 400) return 'tight';
+ if (width >= 280) return 'dense';
+ return 'tiny';
+ }
+
+ function readStored() {
+ try {
+ const value = Number(localStorage.getItem(STORAGE_KEY));
+ return Number.isFinite(value) && value > 0 ? value : null;
+ } catch {
+ return null;
+ }
+ }
+
+ function writeStored(width) {
+ try {
+ if (width === null) localStorage.removeItem(STORAGE_KEY);
+ else localStorage.setItem(STORAGE_KEY, String(width));
+ } catch {
+ /* storage blocked: the width lasts until the page reloads */
+ }
+ }
+
+ function init() {
+ const header = document.querySelector('.header');
+ const right = document.getElementById('headerRight');
+ if (!header || !right || right.querySelector('.header-divider')) return;
+ const brand = header.querySelector('.header-brand');
+ const root = document.documentElement;
+
+ const handle = document.createElement('div');
+ handle.className = 'header-divider';
+ handle.setAttribute('role', 'separator');
+ handle.setAttribute('aria-orientation', 'vertical');
+ handle.setAttribute('aria-label', 'Resize the toolbar (double-click to reset)');
+ handle.setAttribute('aria-valuemin', String(MIN_WIDTH));
+ handle.tabIndex = 0;
+ right.insertBefore(handle, right.firstChild);
+
+ // The width the user asked for. The applied width can be smaller (a narrow window) or larger (a reading
+ // that needs more room), and the request is what comes back when the window grows again.
+ let wanted = readStored();
+
+ const bounds = () => ({ header: header.clientWidth, brand: brand ? brand.offsetWidth : 0 });
+ const current = () => right.getBoundingClientRect().width;
+
+ /** The narrowest the toolbar can be without a reading poking out past its left edge (the divider). */
+ function contentMinimum() {
+ const style = getComputedStyle(right);
+ const frame = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) + parseFloat(style.borderLeftWidth);
+ let widest = 0;
+ for (const child of right.children) {
+ if (child === handle || child.offsetParent === null) continue;
+ widest = Math.max(widest, child.getBoundingClientRect().width, child.scrollWidth);
+ }
+ return Math.ceil(widest + frame);
+ }
+
+ function setDensity(width) {
+ const density = densityFor(width);
+ if (density) root.setAttribute('data-header-density', density);
+ else root.removeAttribute('data-header-density');
+ }
+
+ /** Apply `wanted`, settled on a width no reading overflows at the density that width gets. */
+ function settle() {
+ if (wanted === null) {
+ root.style.removeProperty('--header-right-width');
+ root.removeAttribute('data-header-right-width');
+ root.removeAttribute('data-header-density');
+ handle.removeAttribute('aria-valuenow');
+ handle.removeAttribute('aria-valuemax');
+ return;
+ }
+ const { header: h, brand: b } = bounds();
+ let clamped = clampWidth(wanted, h, b);
+ root.setAttribute('data-header-right-width', 'set');
+ // A narrower toolbar sheds labels, which shrinks the readings, which lowers the minimum.
+ for (let i = 0; i < 3; i++) {
+ root.style.setProperty('--header-right-width', clamped + 'px');
+ setDensity(clamped);
+ const floor = contentMinimum();
+ if (clamped >= floor) break;
+ clamped = floor;
+ }
+ handle.setAttribute('aria-valuenow', String(clamped));
+ handle.setAttribute('aria-valuemax', String(clampWidth(Infinity, h, b)));
+ }
+
+ /** Several changes in one frame (a chip refill, a resize) settle once. */
+ let settleFrame = 0;
+ function scheduleSettle() {
+ if (wanted === null || settleFrame) return;
+ settleFrame = requestAnimationFrame(() => {
+ settleFrame = 0;
+ settle();
+ });
+ }
+
+ /** The tab strip and the subagent lines measure the header, so they re-measure after a user resize. */
+ function relayout() {
+ window.app?.updateTabOverflowMode?.();
+ window.app?.updateConnectionLines?.();
+ }
+
+ function apply(width) {
+ wanted = width === null ? null : Math.max(MIN_WIDTH, Math.round(width));
+ writeStored(wanted);
+ settle();
+ relayout();
+ }
+
+ // Readings arrive after init (the plan-usage chip is filled in asynchronously, a Codex or Copilot row
+ // can start reporting later, a header button can be switched on): re-settle when any child changes size.
+ const resizeObserver = new ResizeObserver(scheduleSettle);
+ const watch = () => {
+ for (const child of right.children) if (child !== handle) resizeObserver.observe(child);
+ };
+ watch();
+ new MutationObserver(() => {
+ watch();
+ scheduleSettle();
+ }).observe(right, { childList: true });
+
+ handle.addEventListener('pointerdown', (event) => {
+ if (event.button !== 0) return;
+ event.preventDefault();
+ handle.setPointerCapture?.(event.pointerId);
+ document.body.classList.add('header-divider-dragging');
+ const padRight = parseFloat(getComputedStyle(header).paddingRight) || 0;
+ let moveFrame = 0;
+ let lastX = event.clientX;
+ const onMove = (e) => {
+ lastX = e.clientX;
+ if (moveFrame) return;
+ moveFrame = requestAnimationFrame(() => {
+ moveFrame = 0;
+ wanted = Math.round(header.getBoundingClientRect().right - lastX - padRight);
+ settle();
+ });
+ };
+ const finish = () => {
+ handle.removeEventListener('pointermove', onMove);
+ handle.removeEventListener('pointerup', finish);
+ handle.removeEventListener('pointercancel', finish);
+ handle.removeEventListener('lostpointercapture', finish);
+ if (moveFrame) cancelAnimationFrame(moveFrame);
+ moveFrame = 0;
+ document.body.classList.remove('header-divider-dragging');
+ if (wanted !== null) apply(wanted);
+ };
+ handle.addEventListener('pointermove', onMove);
+ handle.addEventListener('pointerup', finish);
+ handle.addEventListener('pointercancel', finish);
+ handle.addEventListener('lostpointercapture', finish);
+ });
+
+ handle.addEventListener('dblclick', () => apply(null));
+
+ handle.addEventListener('keydown', (event) => {
+ if (event.key === 'Home') {
+ apply(null);
+ } else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
+ // Step from the width on screen, not the request, so a key held past the floor never leaves a dead zone.
+ apply(current() + (event.key === 'ArrowLeft' ? KEY_STEP : -KEY_STEP));
+ } else {
+ return;
+ }
+ event.preventDefault();
+ });
+
+ window.addEventListener('resize', scheduleSettle);
+ settle();
+ }
+
+ window.CodemanHeaderDivider = { clampWidth, densityFor, MIN_WIDTH, MIN_TABS_ROOM };
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
+ else init();
+})();
diff --git a/src/web/public/index.html b/src/web/public/index.html
index f3a2b2a2..90a262da 100644
--- a/src/web/public/index.html
+++ b/src/web/public/index.html
@@ -4345,6 +4345,7 @@
+
diff --git a/src/web/public/styles.css b/src/web/public/styles.css
index 2009dcb4..00b30ae7 100644
--- a/src/web/public/styles.css
+++ b/src/web/public/styles.css
@@ -2249,6 +2249,111 @@ html[data-tab-orientation='vertical'].tab-rail-compact
margin-left: auto;
padding-left: 0.75rem;
border-left: 1px solid var(--glass-border);
+ position: relative;
+}
+
+/* Draggable edge of the toolbar (header-divider.js). Absolute, so it takes no layout room and the
+ existing border-left stays the visible line. */
+.header-divider {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: -6px;
+ width: 12px;
+ cursor: col-resize;
+ touch-action: none;
+ z-index: 2;
+}
+
+.header-divider:hover,
+.header-divider:focus-visible {
+ background: color-mix(in srgb, var(--accent) 24%, transparent);
+ outline: none;
+}
+
+.header-divider:focus-visible {
+ box-shadow: inset 0 0 0 2px var(--accent);
+}
+
+/* Everything below applies only where the tabs share the header with the toolbar, i.e. where the handle is
+ visible to undo it: not in the sidebar layout, the vertical rail or a solo window, and not on a phone. */
+
+/* A width the user chose: the toolbar keeps it and wraps onto a second line when its content is wider. */
+@media (min-width: 769px) {
+ html[data-header-right-width]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar'])
+ body:not(.solo-mode)
+ .header-right {
+ flex: 0 0 var(--header-right-width);
+ width: var(--header-right-width);
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ align-content: center;
+ row-gap: 0.25rem;
+ min-height: var(--header-height);
+ }
+
+ /* The narrower the toolbar, the less each usage reading shows (data-header-density, header-divider.js):
+ tight drops the window and CPU/MEM labels, dense shortens the provider names, tiny keeps only the rings.
+ The prefix outweighs the per-style rules further down, so every header-stats style follows it. */
+ html[data-header-density]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar'])
+ body:not(.solo-mode)
+ :is(.header-system-stats .stat-label, .header-plan-usage .pu-label) {
+ display: none;
+ }
+
+ html[data-header-density]:not([data-tab-orientation='vertical']):not([data-session-list='sidebar'])
+ body:not(.solo-mode)
+ :is(.header-system-stats, .header-plan-usage) {
+ gap: 8px;
+ padding: 0 8px;
+ }
+
+ html:is([data-header-density='dense'], [data-header-density='tiny']):not([data-tab-orientation='vertical']):not(
+ [data-session-list='sidebar']
+ )
+ body:not(.solo-mode)
+ .header-plan-usage
+ .pu-provider {
+ font-size: 0;
+ }
+
+ html:is([data-header-density='dense'], [data-header-density='tiny']):not([data-tab-orientation='vertical']):not(
+ [data-session-list='sidebar']
+ )
+ body:not(.solo-mode)
+ .header-plan-usage
+ .pu-provider::after {
+ content: attr(data-short);
+ font-size: 0.6rem;
+ }
+
+ /* Rings-only is a Compact-style reading: the other styles have no rings, so they keep their values. */
+ html[data-header-stats='compact'][data-header-density='tiny']:not([data-tab-orientation='vertical']):not(
+ [data-session-list='sidebar']
+ )
+ body:not(.solo-mode)
+ :is(.header-system-stats .stat-value, .header-plan-usage .pu-val) {
+ display: none;
+ }
+}
+
+body.header-divider-dragging,
+body.header-divider-dragging * {
+ cursor: col-resize !important;
+ user-select: none !important;
+}
+
+/* Only where the tabs share the header with the toolbar. */
+html[data-tab-orientation='vertical'] .header-divider,
+html[data-session-list='sidebar'] .header-divider,
+body.solo-mode .header-divider {
+ display: none;
+}
+
+@media (max-width: 768px) {
+ .header-divider {
+ display: none;
+ }
}
/* Connection Indicator */
diff --git a/test/header-divider.browser.test.ts b/test/header-divider.browser.test.ts
new file mode 100644
index 00000000..ebf756eb
--- /dev/null
+++ b/test/header-divider.browser.test.ts
@@ -0,0 +1,278 @@
+/**
+ * Real Chromium, against the real header markup from index.html and the real styles.css: dragging the divider
+ * resizes the toolbar and wraps it onto a second line, the readings shed detail as it narrows, nothing pokes
+ * out past the divider (even for content that arrives later), and none of it applies where the handle is hidden.
+ */
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { chromium, type Browser, type Page } from 'playwright';
+
+const dir = resolve(import.meta.dirname, '../src/web/public');
+const styles = readFileSync(resolve(dir, 'styles.css'), 'utf8');
+const controller = readFileSync(resolve(dir, 'header-divider.js'), 'utf8');
+const header = /