diff --git a/client/app.js b/client/app.js index 153e0893..8e794c40 100644 --- a/client/app.js +++ b/client/app.js @@ -5346,6 +5346,11 @@ class ClaudeOrchestrator { container.id = id; container.className = 'terminal-pair'; container.dataset.worktreeKey = String(key || ''); + + const header = document.createElement('div'); + header.className = 'terminal-pair-header'; + container.appendChild(header); + grid.appendChild(container); } else { grid.appendChild(container); @@ -5427,6 +5432,140 @@ class ClaudeOrchestrator { } }); + if (visibleCount > 0 && ordered.length > 0) { + const firstSessionId = ordered[0]; + const firstSession = this.sessions.get(firstSessionId); + if (firstSession) { + const repositoryName = this.extractRepositoryName(firstSessionId); + const worktreeId = firstSession.worktreeId || group.key; + const displayName = repositoryName ? `${repositoryName}/${worktreeId}` : worktreeId.replace('work', ''); + const branchMeta = this.formatBranchLabel(firstSession.branch || '', { context: 'terminal' }); + const branchRefreshId = encodeURIComponent(String(firstSessionId || '')); + const terminalVisibility = this.getTerminalVisibilityConfig(); + const showBranchRefresh = terminalVisibility.branchRefresh !== false; + + const agentSid = ordered.find(sid => !String(sid).endsWith('-server')); + const serverSid = ordered.find(sid => String(sid).endsWith('-server')); + + const agentBtnClass = agentSid && visibleSet.has(agentSid) ? 'active' : 'inactive'; + const serverBtnClass = serverSid && visibleSet.has(serverSid) ? 'active' : 'inactive'; + + const header = container.querySelector('.terminal-pair-header'); + if (header) { + header.innerHTML = ` +
+ πŸ“ ${displayName} + ${this.escapeHtml(branchMeta.text || '')} + ${showBranchRefresh ? `` : ''} +
+
+
+
+ ${agentSid ? `` : ''} + ${serverSid ? `` : ''} +
+
+ `; + + // Attach events + header.querySelectorAll('.toggle-visibility-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + const sid = e.currentTarget.dataset.sid; + const type = e.currentTarget.dataset.type; + const isHiding = e.currentTarget.classList.contains('active'); + + if (isHiding) { + // Check if the other one is also inactive (or going to be) + const otherType = type === 'agent' ? 'server' : 'agent'; + const otherBtn = header.querySelector(`.toggle-visibility-btn[data-type="${otherType}"]`); + const isOtherHidden = !otherBtn || otherBtn.classList.contains('inactive'); + + if (isOtherHidden) { + // Both will be hidden -> fully hide worktree + this.toggleWorktreeVisibility(worktreeId, false); + return; + } + } + + this.toggleSessionVisibility(sid); + }); + }); + } + } + } + + // Manage splitter + if (visibleCount > 1) { + const wrappers = Array.from(container.querySelectorAll('.terminal-wrapper')).filter(el => el.style.display !== 'none'); + if (wrappers.length === 2) { + let splitter = container.querySelector('.terminal-splitter'); + if (!splitter) { + splitter = document.createElement('div'); + splitter.className = 'terminal-splitter'; + container.insertBefore(splitter, wrappers[1]); + + let isDragging = false; + let startX = 0; + let startWidthLeft = 0; + let startWidthRight = 0; + + const onMouseMove = (e) => { + if (!isDragging) return; + e.preventDefault(); + const deltaX = e.clientX - startX; + + const newLeftWidth = Math.max(150, startWidthLeft + deltaX); + const newRightWidth = Math.max(150, startWidthRight - deltaX); + + const totalWidth = newLeftWidth + newRightWidth; + const leftRatio = newLeftWidth / totalWidth; + const rightRatio = newRightWidth / totalWidth; + + // We use CSS Grid now! Update grid-template-columns directly on the pair. + container.style.gridTemplateColumns = `${leftRatio}fr auto ${rightRatio}fr`; + + // Trigger resize for xterm + if (this.terminalManager) { + wrappers.forEach(w => { + const sid = w.dataset.sessionId; + if (sid) requestAnimationFrame(() => this.terminalManager.fitTerminal(sid)); + }); + } + }; + + const onMouseUp = () => { + isDragging = false; + splitter.classList.remove('dragging'); + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + document.body.style.cursor = ''; + }; + + splitter.addEventListener('mousedown', (e) => { + isDragging = true; + startX = e.clientX; + + const leftWrapper = container.querySelector('.terminal-wrapper[data-session-type="claude"]') || wrappers[0]; + const rightWrapper = container.querySelector('.terminal-wrapper[data-session-type="server"]') || wrappers[1]; + + startWidthLeft = leftWrapper.getBoundingClientRect().width; + startWidthRight = rightWrapper.getBoundingClientRect().width; + + splitter.classList.add('dragging'); + document.body.style.cursor = 'col-resize'; + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + } + } + } else { + const splitter = container.querySelector('.terminal-splitter'); + if (splitter) splitter.remove(); + + // Reset grid columns if only 1 is visible + container.style.gridTemplateColumns = ''; + } + container.classList.toggle('terminal-pair-single', visibleCount <= 1); if (visibleCount) { container.style.display = ''; @@ -5445,10 +5584,9 @@ class ClaudeOrchestrator { // Set the data attribute for dynamic layout based on visible count const visibleCount = activeGroupKeys.size; grid.setAttribute('data-visible-count', visibleCount); - // If the user has more than 16 visible terminals, fall back to a scrollable grid + // If the user has more than 4 visible terminals, fall back to a scrollable grid // instead of clipping extra rows (which shows up as tiny β€œslivers” at the bottom). - grid.classList.toggle('terminal-grid-scrollable', visibleCount > 16); - + // Force a resize after everything is rendered to ensure terminals fit properly setTimeout(() => { this.resizeAllVisibleTerminals(); @@ -5854,7 +5992,14 @@ class ClaudeOrchestrator { const wrapper = this.getSessionWrapperElement(sid); if (!wrapper) return; - const titleRow = wrapper.querySelector('.terminal-title'); + let titleRow = wrapper.querySelector('.terminal-title'); + const pairContainer = wrapper.closest('.terminal-pair'); + if (pairContainer) { + const pairTicketContainer = pairContainer.querySelector('.terminal-pair-ticket-container'); + if (pairTicketContainer) { + titleRow = pairTicketContainer; + } + } if (!titleRow) return; const existing = titleRow.querySelector('.terminal-ticket'); @@ -5958,10 +6103,7 @@ class ClaudeOrchestrator {
- ${isAgentSession ? 'πŸ€– Agent' : 'πŸ’» Server'} ${displayName} - ${this.escapeHtml(branchMeta.text || '')} - ${showBranchRefresh ? `` : ''} - ${ticketChip} + ${isAgentSession ? 'πŸ€– Agent' : 'πŸ’» Server'}
${isAgentSession ? ` @@ -15597,7 +15739,7 @@ class ClaudeOrchestrator { const isAgentSession = /-(claude|codex)$/.test(String(sessionId || '')); const worktreeNumber = sessionId.split('-')[0].replace('work', ''); - if (focusedTitle) focusedTitle.textContent = `${isAgentSession ? 'πŸ€– Agent' : 'πŸ’» Server'} ${worktreeNumber}`; + if (focusedTitle) focusedTitle.textContent = `${isAgentSession ? 'πŸ€–' : 'πŸ’»'} ${worktreeNumber}`; if (focusedBranch) focusedBranch.textContent = this.formatBranchLabel(session.branch || '', { context: 'terminal' }).text || ''; if (focusedStatus) focusedStatus.className = `status-indicator ${session.status || 'idle'}`; diff --git a/client/commander-panel.js b/client/commander-panel.js index daae8ab5..234c74c9 100644 --- a/client/commander-panel.js +++ b/client/commander-panel.js @@ -218,12 +218,17 @@ class CommanderPanel { container._commanderPasteHandler = onPaste; } - // Click to focus + // Click or hover to focus (auto-focus) container.addEventListener('click', () => { if (this.terminal) { this.terminal.focus(); } }); + container.addEventListener('mousemove', () => { + if (this.terminal && (!this.terminal.hasSelection || !this.terminal.hasSelection()) && document.activeElement !== this.terminal.textarea) { + this.terminal.focus(); + } + }); // Handle resize window.addEventListener('resize', () => { diff --git a/client/styles.css b/client/styles.css index e5cc5559..a3a37621 100644 --- a/client/styles.css +++ b/client/styles.css @@ -1343,149 +1343,150 @@ header { /* Terminal Grid */ .terminal-grid { - padding: var(--space-md); - overflow: hidden; + padding: var(--space-sm); + overflow: hidden; /* No scrolling! */ display: grid; - gap: var(--space-md); - /* Dynamic grid - automatically adjusts based on visible terminals */ - grid-template-columns: repeat(4, 1fr); - grid-template-rows: repeat(4, 1fr); /* Default 4x4 grid */ - /* Fill the remaining viewport via flex instead of hardcoded viewport math. */ + gap: var(--space-sm); + + /* Default to auto, but mostly rely on data-visible-count overrides below */ + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); + flex: 1 1 auto; min-height: 0; - height: auto; - contain: layout; -} - -.terminal-pair { - display: flex; - flex-direction: column; - gap: var(--space-md); - min-height: 0; height: 100%; } -.terminal-pair > .terminal-wrapper { - flex: 1 1 0; - min-height: 0; -} -.terminal-pair.terminal-pair-single { - gap: 0; -} +/* Responsive no-scroll matrices for terminal pairs */ -/* Dynamic layouts for different terminal counts - Pair-aware (Claude + Server) */ - -/* Single terminal (shouldn't happen often) */ .terminal-grid[data-visible-count="1"] { - grid-template-columns: 1fr; - grid-template-rows: 1fr; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); } -/* 1 pair (2 terminals): Side by side */ .terminal-grid[data-visible-count="2"] { - grid-template-columns: repeat(2, 1fr); - grid-template-rows: 1fr; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: minmax(0, 1fr); } -/* Odd terminal (3) - probably 1 pair + 1 solo */ .terminal-grid[data-visible-count="3"] { - grid-template-columns: repeat(3, 1fr); - grid-template-rows: 1fr; + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-rows: minmax(0, 1fr); } -/* 2 pairs (4 terminals): 2x2 grid */ .terminal-grid[data-visible-count="4"] { - grid-template-columns: repeat(2, 1fr); - grid-template-rows: repeat(2, 1fr); + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(0, 1fr)); } -/* 5 terminals - 2 pairs + 1 solo */ .terminal-grid[data-visible-count="5"] { - grid-template-columns: repeat(3, 1fr); - grid-template-rows: repeat(2, 1fr); + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(0, 1fr)); } -/* 3 pairs (6 terminals): Stack 3 pairs vertically */ .terminal-grid[data-visible-count="6"] { - grid-template-columns: repeat(2, 1fr); - grid-template-rows: repeat(3, 1fr); -} - -/* 7 terminals - 3 pairs + 1 solo */ -.terminal-grid[data-visible-count="7"] { - grid-template-columns: repeat(4, 1fr); - grid-template-rows: repeat(2, 1fr); + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(0, 1fr)); } -/* 4 pairs (8 terminals): Stack 4 pairs vertically */ +.terminal-grid[data-visible-count="7"], .terminal-grid[data-visible-count="8"] { - grid-template-columns: repeat(2, 1fr); - grid-template-rows: repeat(4, 1fr); + grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(0, 1fr)); } -/* 9 terminals */ .terminal-grid[data-visible-count="9"] { - grid-template-columns: repeat(3, 1fr); - grid-template-rows: repeat(3, 1fr); + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-rows: repeat(3, minmax(0, 1fr)); } -/* 5 pairs (10 terminals): 4x2 grid with last pair spanning bottom */ -.terminal-grid[data-visible-count="10"] { - grid-template-columns: repeat(4, 1fr); - grid-template-rows: repeat(3, 1fr); -} -/* Make LAST 2 terminals span the bottom row (using nth-last-child for flexibility) */ -.terminal-grid[data-visible-count="10"] > :nth-last-child(2) { - grid-column: 1 / span 2; +.terminal-grid[data-visible-count="10"], +.terminal-grid[data-visible-count="11"], +.terminal-grid[data-visible-count="12"] { + grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-rows: repeat(3, minmax(0, 1fr)); } -.terminal-grid[data-visible-count="10"] > :nth-last-child(1) { - grid-column: 3 / span 2; + +.terminal-grid[data-visible-count="13"], +.terminal-grid[data-visible-count="14"], +.terminal-grid[data-visible-count="15"], +.terminal-grid[data-visible-count="16"] { + grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-rows: repeat(4, minmax(0, 1fr)); } -/* 11 terminals - odd case */ -.terminal-grid[data-visible-count="11"] { - grid-template-columns: repeat(4, 1fr); - grid-template-rows: repeat(3, 1fr); + +/* Pair Layout (Agent + Server) */ +.terminal-grid { align-items: stretch; } + +.terminal-pair { + display: grid; + grid-template-columns: 1fr auto 1fr; + grid-template-rows: auto 1fr; + gap: 0; + min-height: 0; /* Let it shrink to fit! */ + height: 100%; + background: var(--bg-tertiary); + border-radius: var(--radius-md); + border: 1px solid var(--border-color); + overflow: hidden; + box-shadow: 0 4px 6px rgba(0,0,0,0.1); + transition: border-color 0.2s, box-shadow 0.2s; } -/* 6 pairs (12 terminals): 4 columns x 3 rows works perfectly */ -.terminal-grid[data-visible-count="12"] { - grid-template-columns: repeat(4, 1fr); - grid-template-rows: repeat(3, 1fr); +.terminal-pair-header { + grid-column: 1 / -1; + grid-row: 1; + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-wrap: nowrap; + gap: var(--space-sm); + background: linear-gradient(to bottom, var(--bg-secondary) 0%, var(--bg-tertiary) 100%); + padding: 4px 8px; /* Extremely compact padding */ + border-bottom: 1px solid var(--border-color); } -/* 7 pairs (14 terminals): 4x4 grid with last pair spanning bottom */ -.terminal-grid[data-visible-count="13"], -.terminal-grid[data-visible-count="14"] { - grid-template-columns: repeat(4, 1fr); - grid-template-rows: repeat(4, 1fr); +.terminal-pair > .terminal-wrapper { + grid-row: 2; + min-height: 0; /* Let it shrink down infinitely */ + min-width: 0; /* Let it shrink horizontally infinitely */ + border: none; + border-radius: 0; + box-shadow: none; + border-right: 1px solid var(--border-color); } -/* Make LAST 2 terminals span the bottom row (using nth-last-child for flexibility) */ -.terminal-grid[data-visible-count="14"] > :nth-last-child(2) { - grid-column: 1 / span 2; +.terminal-pair > .terminal-wrapper:last-child { + border-right: none; } -.terminal-grid[data-visible-count="14"] > :nth-last-child(1) { - grid-column: 3 / span 2; + +.terminal-splitter { + grid-row: 2; + width: 6px; + height: 100%; + background: var(--bg-secondary); + cursor: col-resize; + border-left: 1px solid var(--border-color); + border-right: 1px solid var(--border-color); + transition: background 0.2s; + z-index: 10; + margin-left: -1px; /* collapse border */ } -/* 8 pairs (16 terminals) */ -.terminal-grid[data-visible-count="15"], -.terminal-grid[data-visible-count="16"] { - grid-template-columns: repeat(4, 1fr); - grid-template-rows: repeat(4, 1fr); +.terminal-splitter:hover, .terminal-splitter.dragging { + background: var(--accent-primary); } -/* If the user has more than 16 visible terminals, allow scrolling and use - fixed-ish row heights so additional terminals don't become inaccessible. */ -.terminal-grid.terminal-grid-scrollable { - overflow-y: auto; - overflow-x: hidden; - align-content: stretch; - grid-template-rows: minmax(260px, auto); - grid-auto-rows: minmax(260px, auto); +/* If there's only one terminal, it should span both columns */ +.terminal-pair.terminal-pair-single > .terminal-wrapper { + grid-column: 1 / -1; + border-right: none; } + + + /* Terminal Container */ .terminal-wrapper { background: var(--bg-tertiary); @@ -1494,19 +1495,29 @@ header { overflow: hidden; display: flex; flex-direction: column; - height: 100%; /* Fill grid cell completely */ + height: 100%; width: 100%; position: relative; isolation: isolate; } +.terminal-wrapper[data-session-type="claude"] { + border-top: 3px solid var(--accent-primary); +} + +.terminal-wrapper[data-session-type="server"] { + border-top: 3px solid #10b981; /* Green accent for server */ +} + .terminal-header { - background: var(--bg-secondary); - padding: var(--space-sm) var(--space-md); + background: var(--bg-tertiary); + padding: 2px 8px; /* Extremely compact padding */ display: flex; align-items: center; + justify-content: space-between; gap: var(--space-sm); border-bottom: 1px solid var(--border-color); + flex-wrap: wrap; } .terminal-intent-haiku { @@ -1535,11 +1546,18 @@ header { .terminal-title { font-weight: 600; font-size: 0.875rem; - flex: 1; + flex: 1 1 min-content; display: flex; + flex-wrap: wrap; align-items: center; gap: var(--space-sm); - min-width: 0; +} + +.terminal-title > span:nth-child(2) { + white-space: nowrap; + flex: 0 1 auto; + font-size: 0.85rem; + color: var(--text-secondary); } .terminal-branch { @@ -1547,11 +1565,16 @@ header { color: var(--accent-primary); font-family: var(--font-mono); font-weight: 600; - margin-left: var(--space-sm); + margin-left: 0; background: rgba(99, 179, 237, 0.1); padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--accent-primary); + + /* Never cut with dots! Allow it to wrap normally. */ + word-break: break-word; + white-space: normal; + flex: 0 1 auto; } .terminal-branch:empty { @@ -1664,6 +1687,8 @@ header { gap: var(--space-xs); align-items: center; flex-wrap: wrap; + flex: 0 0 auto; + /* Instead of margin-left: auto, rely on justify-content: space-between on header */ } .terminal-ticket { @@ -1671,15 +1696,15 @@ header { color: var(--text-secondary); font-family: var(--font-mono); font-weight: 600; - margin-left: var(--space-sm); + margin-left: 0; background: rgba(99, 179, 237, 0.08); padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-color); - max-width: min(360px, 40vw); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + + /* Stop truncation, show the ticket fully */ + word-break: break-word; + white-space: normal; display: inline-flex; align-items: center; text-decoration: none; @@ -6320,7 +6345,7 @@ body.dependency-onboarding-active #dependency-setup-modal { /* Responsive - ONLY apply if data-visible-count is not set */ @media (max-width: 1200px) { .terminal-grid:not([data-visible-count]) { - grid-template-columns: repeat(2, 1fr); + grid-template-columns: repeat(2, minmax(0, 1fr)); } } @@ -6388,7 +6413,7 @@ body.dependency-onboarding-active #dependency-setup-modal { } .terminal-grid:not([data-visible-count]) { - grid-template-columns: 1fr; + grid-template-columns: minmax(0, 1fr); } } @@ -9013,14 +9038,7 @@ body.dependency-onboarding-active #dependency-setup-modal { ======================================== */ /* Smaller desktop screens and landscape tablets */ -@media (max-height: 900px) { - .terminal-grid { - grid-template-rows: repeat(2, minmax(250px, 1fr)); /* Reduced min height */ - } - .terminal-wrapper { - min-height: 250px; /* Match grid min height */ - } .startup-ui-compact { padding: var(--space-sm); @@ -9049,11 +9067,7 @@ body.dependency-onboarding-active #dependency-setup-modal { --header-height: 50px; /* Reduced header height */ } - .terminal-grid { - grid-template-rows: repeat(2, minmax(220px, 1fr)); - padding: var(--space-sm); - gap: var(--space-sm); - } + .terminal-grid { padding: var(--space-sm); gap: var(--space-sm); } .terminal-wrapper { min-height: 220px; @@ -9099,11 +9113,7 @@ body.dependency-onboarding-active #dependency-setup-modal { --header-height: 45px; } - .terminal-grid { - grid-template-rows: repeat(2, minmax(200px, 1fr)); - padding: 6px; - gap: 6px; - } + .terminal-grid { padding: 6px; gap: 6px; } .terminal-wrapper { min-height: 200px; @@ -9159,14 +9169,7 @@ body.dependency-onboarding-active #dependency-setup-modal { } /* Ultra-compact for very constrained heights */ -@media (max-height: 480px) { - .terminal-grid { - grid-template-rows: repeat(2, minmax(180px, 1fr)); - } - .terminal-wrapper { - min-height: 180px; - } .startup-ui-compact { padding: 4px; @@ -14577,3 +14580,31 @@ body.dependency-onboarding-active #dependency-setup-modal { grid-template-columns: 1fr; } } + +.terminal-pair > .terminal-wrapper[data-session-type="claude"] { + order: 1; +} +.terminal-splitter { + order: 2; +} +.terminal-pair > .terminal-wrapper[data-session-type="server"] { + order: 3; +} + +/* Enforce strict horizontal positions so unhiding never swaps them */ +.terminal-pair > .terminal-wrapper[data-session-type="claude"] { + grid-column: 1; +} + +.terminal-splitter { + grid-column: 2; +} + +.terminal-pair > .terminal-wrapper[data-session-type="server"] { + grid-column: 3; +} + +/* If single mode is active, the single remaining terminal overrides the above rules to span both */ +.terminal-pair.terminal-pair-single > .terminal-wrapper { + grid-column: 1 / -1 !important; +} diff --git a/client/terminal.js b/client/terminal.js index 9a23024f..3bf0d6f2 100644 --- a/client/terminal.js +++ b/client/terminal.js @@ -347,11 +347,21 @@ class TerminalManager { }); }); - // Focus terminal when clicked + // Focus terminal when clicked or hovered (auto-focus) terminalElement.addEventListener('click', () => { terminal.focus(); }); + // Use the parent wrapper for more reliable auto-focus + const wrapper = terminalElement.closest('.terminal-wrapper') || terminalElement; + wrapper.addEventListener('mousemove', () => { + // Only focus if there's no selection, to avoid interrupting copy operations. + // Also check if we aren't already focused to avoid overhead. + if (!terminal.hasSelection() && document.activeElement !== terminal.textarea) { + terminal.focus(); + } + }); + // Auto-focus if it's a Claude session if (sessionId.includes('claude')) { setTimeout(() => terminal.focus(), 100);