feat(grid): desktop split-grid watch board — v1 (single/1×2/2×2)

When several sessions are open on a large screen (≥1024px), the terminal
area can split into 1×2 or 2×2 so multiple LIVE, interactive terminals show
at once — a monitoring convenience for vibe-coding several Claude sessions.

Design keeps activeIndex as the single focused pane, so keybar/voice/approval
routing is unchanged; a new gridLayout + derived visible-set lets several
.term-cell wrappers show together inside a CSS-grid #term. The server and WS
protocol are untouched.

- public/grid-layout.ts (new): layout types/capacity, visibleIndices,
  matchMedia desktop gate (GRID_MIN_WIDTH=1024), persistence, toolbar toggle.
- tabs.ts: pane→.term-cell wrapper (header + terminal + inline-approve footer);
  applyLayout() owns show/hide + grid class + cell order + placeholders;
  board-aware activate() (never focuses a hidden pane); setFocused/setGridLayout
  delegate to it; renderCell/renderInlineApprove; refitVisible; notification
  suppression for on-screen panes (factoring in the home overlay).
- terminal-session.ts: show({focus}) so non-focused quadrants don't steal
  keyboard focus; onFocus callback (capture-phase pointerdown).
- main.ts: mount the toggle; window-focus refit → refitVisible.
- style.css: cell/grid model (.term-pane → relative flex child), focus ring,
  pending pulse, inline approve, placeholder, toggle + coarse-pointer targets.
- tests: grid-layout.test.ts + split-grid block in tabs.test.ts (+33).

Adversarial review (4 lenses → per-finding verify) caught and fixed a HIGH:
activate() was not board-aware, so opening a tab on a full grid focused a
hidden pane (typing into an invisible session). Verified: typecheck + build:web
clean, 1566 tests pass, grid-layout 95% / tabs 94% coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2026-07-11 19:19:53 +02:00
parent e254918b1c
commit 06814ba276
9 changed files with 1171 additions and 16 deletions

180
public/grid-layout.ts Normal file
View File

@@ -0,0 +1,180 @@
/**
* public/grid-layout.ts — desktop split-grid ("watch board") layout logic + toggle.
*
* When several sessions are open on a large screen, the user can split #term into
* a 2×2 grid (or 1×2 side-by-side) so multiple LIVE terminals are visible at once
* — a monitoring/multitasking convenience for vibe-coding several Claude sessions.
*
* This module owns the PURE layout logic (capacity, which tabs are visible, the
* matchMedia desktop gate, persistence) plus the toolbar toggle control. The DOM
* of the panes themselves stays in tabs.ts (TabApp) — this keeps tabs.ts from
* growing past its size budget and makes the layout math unit-testable in isolation.
*
* v1 layouts: 'single' (today's behavior) · 'split-2' (side-by-side) · 'grid-4' (2×2).
* The design is deliberately extensible: adding 'row-3'/'grid-6' in v2 is a
* CAPACITY entry + a LAYOUT_META entry + CSS, with no change to the state model.
*/
/** A screen layout for the terminal area. Ordered by pane capacity. */
export type GridLayout = 'single' | 'split-2' | 'grid-4'
/** All layouts, in the order the toggle presents them. */
export const GRID_LAYOUTS: readonly GridLayout[] = ['single', 'split-2', 'grid-4']
const CAPACITY: Readonly<Record<GridLayout, number>> = {
single: 1,
'split-2': 2,
'grid-4': 4,
}
/** How many panes a layout shows at once. */
export function layoutCapacity(layout: GridLayout): number {
return CAPACITY[layout] ?? 1
}
/**
* The tab indices visible under `layout`: the first `min(tabCount, capacity)` in
* tab order. Membership is controlled by reordering tabs (v1) — drag a tab into
* the first N and it joins the board.
*/
export function visibleIndices(tabCount: number, layout: GridLayout): number[] {
const n = Math.min(Math.max(0, tabCount), layoutCapacity(layout))
return Array.from({ length: n }, (_, i) => i)
}
/**
* Whether tab `idx` is on-screen. In 'single' that's only the focused pane; in a
* grid it's any of the first-N visible tabs. Used for both rendering and to
* suppress OS notifications for panes the user can already see.
*/
export function isVisibleIndex(
idx: number,
activeIndex: number,
tabCount: number,
layout: GridLayout,
): boolean {
if (layout === 'single') return idx === activeIndex
return idx >= 0 && idx < Math.min(Math.max(0, tabCount), layoutCapacity(layout))
}
/** Minimum viewport width (px) at which multi-pane layouts are offered. Four
* terminals need real width; below this the board falls back to 'single'. */
export const GRID_MIN_WIDTH = 1024
/** True when the screen is wide enough for split layouts (desktop/large only). */
export function gridAllowed(): boolean {
if (typeof window === 'undefined') return false
if (typeof window.matchMedia === 'function') {
return window.matchMedia(`(min-width: ${GRID_MIN_WIDTH}px)`).matches
}
return (window.innerWidth ?? 0) >= GRID_MIN_WIDTH
}
const GRID_LAYOUT_KEY = 'web-terminal:grid-layout'
function isGridLayout(v: unknown): v is GridLayout {
return v === 'single' || v === 'split-2' || v === 'grid-4'
}
/** Load the persisted layout. Forced to 'single' when the screen is too narrow
* (a stored grid choice must not resurrect on a phone). */
export function loadGridLayout(): GridLayout {
if (!gridAllowed()) return 'single'
try {
const stored = localStorage.getItem(GRID_LAYOUT_KEY)
if (isGridLayout(stored)) return stored
} catch {
// localStorage unavailable — use default
}
return 'single'
}
/** Persist the chosen layout (mirrors the other web-terminal:* prefs). */
export function saveGridLayout(layout: GridLayout): void {
try {
localStorage.setItem(GRID_LAYOUT_KEY, layout)
} catch {
// localStorage unavailable — run without persistence
}
}
export interface GridToggleHooks {
getLayout: () => GridLayout
setLayout: (layout: GridLayout) => void
}
export interface GridToggle {
/** Re-sync the control's pressed state + visibility (after setLayout / resize). */
refresh: () => void
dispose: () => void
}
/** Per-layout label/title for the toggle. The glyph is drawn in CSS from the
* cell count (see makeGlyph), not from a font, so it renders identically everywhere. */
const LAYOUT_META: Readonly<Record<GridLayout, { title: string; cells: number }>> = {
single: { title: 'Single pane', cells: 1 },
'split-2': { title: 'Side by side — compare two', cells: 2 },
'grid-4': { title: '2×2 watch board', cells: 4 },
}
/** A small CSS-drawn layout glyph (N cells), reliable across platforms. */
function makeGlyph(layout: GridLayout): HTMLElement {
const g = document.createElement('span')
g.className = `grid-glyph gl-${layout}`
g.setAttribute('aria-hidden', 'true')
for (let i = 0; i < LAYOUT_META[layout].cells; i++) g.appendChild(document.createElement('i'))
return g
}
/**
* Mount the layout segmented control into the toolbar. Desktop-only: it hides
* below GRID_MIN_WIDTH, and if the window narrows past the threshold while a grid
* is active it forces the layout back to 'single' (the caller re-renders).
*/
export function mountGridToggle(toolbar: HTMLElement, hooks: GridToggleHooks): GridToggle {
const seg = document.createElement('div')
seg.className = 'grid-toggle'
seg.setAttribute('role', 'group')
seg.setAttribute('aria-label', 'Terminal layout')
const buttons = GRID_LAYOUTS.map((layout) => {
const btn = document.createElement('button')
btn.className = 'grid-toggle-btn'
btn.dataset.layout = layout
btn.title = LAYOUT_META[layout].title
btn.setAttribute('aria-label', LAYOUT_META[layout].title)
btn.appendChild(makeGlyph(layout))
btn.addEventListener('click', () => hooks.setLayout(layout))
seg.appendChild(btn)
return btn
})
const refresh = (): void => {
seg.style.display = gridAllowed() ? 'inline-flex' : 'none'
const current = hooks.getLayout()
for (const btn of buttons) {
btn.setAttribute('aria-pressed', String(btn.dataset.layout === current))
}
}
const mql =
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
? window.matchMedia(`(min-width: ${GRID_MIN_WIDTH}px)`)
: null
const onChange = (): void => {
if (!gridAllowed() && hooks.getLayout() !== 'single') hooks.setLayout('single')
refresh()
}
mql?.addEventListener('change', onChange)
toolbar.appendChild(seg)
refresh()
return {
refresh,
dispose: (): void => {
mql?.removeEventListener('change', onChange)
seg.remove()
},
}
}

View File

@@ -20,6 +20,7 @@ import { mountDashboard } from './dashboard.js'
import { mountHistory } from './history.js'
import { mountShortcuts } from './shortcuts.js'
import { mountShareSession } from './share.js'
import { mountGridToggle } from './grid-layout.js'
const paneHost = document.getElementById('term')
const tabs = document.getElementById('tabs')
@@ -50,12 +51,12 @@ mountKeybar((data) => app.sendToActive(data), {
onVoiceTrigger: (action) => app.handleVoiceTrigger(action),
})
// Multi-device: when this device regains focus, re-assert the active tab's size
// so a shared session snaps to THIS screen (latest-writer-wins) — full-screen on
// whichever device you're currently using.
window.addEventListener('focus', () => app.refitActive())
// Multi-device: when this device regains focus, re-assert the size of every
// VISIBLE pane so a shared session snaps to THIS screen (latest-writer-wins) —
// full-screen on whichever device you're using (all quadrants in a split grid).
window.addEventListener('focus', () => app.refitVisible())
document.addEventListener('visibilitychange', () => {
if (!document.hidden) app.refitActive()
if (!document.hidden) app.refitVisible()
})
// Toolbar utilities.
@@ -81,6 +82,15 @@ mountHistory(toolbar, {
mountShortcuts(toolbar)
mountShareSession(toolbar, () => app.activeSessionId())
// Split-grid layout toggle (desktop-only; hidden below GRID_MIN_WIDTH). The
// control drives app.setGridLayout and is registered back so a programmatic
// layout change (e.g. auto-fallback on window narrow) re-syncs its pressed state.
const gridToggle = mountGridToggle(toolbar, {
getLayout: () => app.getGridLayout(),
setLayout: (layout) => app.setGridLayout(layout),
})
app.setGridToggle(gridToggle)
// Session management lives on the home Sessions chooser now (open / kill /
// new), so the standalone /manage.html page was removed.

View File

@@ -298,14 +298,276 @@ body {
background: var(--bg);
overflow: hidden;
}
.term-pane {
/* Each pane lives in a .term-cell wrapper (header + terminal + optional inline
* approve). In single mode the cell fills #term absolutely (only the focused one
* is shown) so the classic one-pane look is unchanged; split layouts turn #term
* into a CSS grid and the cells flow into tracks. */
.term-cell {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.term-pane {
position: relative;
flex: 1 1 auto;
min-height: 0;
padding: 6px 8px 0;
box-sizing: border-box;
overflow: hidden;
}
/* Cell header + inline-approve footer are hidden in single mode. */
.cell-head,
.cell-approve {
display: none;
}
/* ── Split-grid layouts (desktop watch board) ────────────────────────── */
#term.lay-split-2,
#term.lay-grid-4 {
display: grid;
gap: 6px;
padding: 6px;
}
#term.lay-split-2 {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr;
}
#term.lay-grid-4 {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
#term.lay-split-2 .term-cell,
#term.lay-grid-4 .term-cell {
position: relative;
inset: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
}
#term.lay-split-2 .cell-head,
#term.lay-grid-4 .cell-head {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 9px;
background: linear-gradient(180deg, var(--surface-2), var(--surface-1));
border-bottom: 1px solid var(--border);
font-size: 12px;
cursor: pointer;
user-select: none;
}
.cell-name {
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cell-status {
margin-left: auto;
font-size: 11px;
color: var(--text-dim);
white-space: nowrap;
}
.cell-status.cs-working {
color: var(--amber);
}
.cell-status.cs-waiting {
color: var(--accent);
}
.cell-status.cs-idle {
color: var(--green);
}
.cell-status.cs-stuck {
color: var(--red);
}
/* Focus ring — the quadrant that owns keyboard / keybar / voice / approvals. */
#term.lay-split-2 .term-cell.focused,
#term.lay-grid-4 .term-cell.focused {
border-color: var(--accent);
box-shadow:
0 0 0 1px var(--accent),
var(--shadow);
}
/* Pending glow — a non-focused quadrant is waiting for approval. */
#term.lay-split-2 .term-cell.pending:not(.focused),
#term.lay-grid-4 .term-cell.pending:not(.focused) {
border-color: var(--amber);
animation: cell-pending 2.4s ease-in-out infinite;
}
@keyframes cell-pending {
0%,
100% {
box-shadow: 0 0 0 1px rgba(245, 177, 76, 0.35);
}
50% {
box-shadow:
0 0 0 1px rgba(245, 177, 76, 0.7),
0 0 26px -8px rgba(245, 177, 76, 0.6);
}
}
@media (prefers-reduced-motion: reduce) {
#term.lay-split-2 .term-cell.pending,
#term.lay-grid-4 .term-cell.pending {
animation: none;
}
}
/* Inline per-quadrant approve footer. */
#term.lay-split-2 .cell-approve,
#term.lay-grid-4 .cell-approve {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 9px;
background: rgba(245, 177, 76, 0.08);
border-top: 1px solid rgba(245, 177, 76, 0.35);
font-size: 11.5px;
}
.cell-approve-label {
color: var(--amber);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cell-approve-btns {
margin-left: auto;
display: flex;
gap: 6px;
}
.cell-approve-yes,
.cell-approve-no {
border: 0;
border-radius: 6px;
padding: 3px 11px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.cell-approve-yes {
background: var(--green);
color: #06231a;
}
.cell-approve-no {
background: var(--surface-3);
color: var(--text-dim);
}
.cell-approve-yes:hover {
filter: brightness(1.1);
}
.cell-approve-no:hover {
color: var(--text);
}
/* Empty-slot placeholder (fewer sessions than the layout holds). */
.term-cell.slot-empty {
border: 1px dashed var(--border-strong);
background: repeating-linear-gradient(
-45deg,
transparent,
transparent 10px,
rgba(255, 255, 255, 0.02) 10px,
rgba(255, 255, 255, 0.02) 20px
);
align-items: center;
justify-content: center;
cursor: pointer;
}
.term-cell.slot-empty:hover {
border-color: var(--accent);
}
.slot-new {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
background: none;
border: 0;
color: var(--text-faint);
font-size: 12px;
font-family: var(--ui-font);
cursor: pointer;
}
.slot-new b {
font-size: 26px;
font-weight: 300;
line-height: 1;
color: var(--text-dim);
}
.term-cell.slot-empty:hover .slot-new {
color: var(--text-dim);
}
/* ── Grid-toggle segmented control (toolbar) ─────────────────────────── */
.grid-toggle {
display: inline-flex;
align-items: center;
gap: 2px;
margin-left: 4px;
padding: 2px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 8px;
}
.grid-toggle-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 26px;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-faint);
cursor: pointer;
}
.grid-toggle-btn:hover {
color: var(--text);
background: var(--surface-3);
}
.grid-toggle-btn[aria-pressed='true'] {
color: var(--on-accent);
background: var(--accent);
}
.grid-glyph {
display: grid;
gap: 1.5px;
width: 14px;
height: 12px;
}
.grid-glyph.gl-single {
grid-template-columns: 1fr;
}
.grid-glyph.gl-split-2 {
grid-template-columns: 1fr 1fr;
}
.grid-glyph.gl-grid-4 {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
.grid-glyph i {
background: currentColor;
border-radius: 1px;
}
/* Touch tablets clear the 1024px width gate but need bigger tap targets — mirror
* the project's existing coarse-pointer bump for the tab/key bars. */
@media (pointer: coarse) {
.grid-toggle {
gap: 3px;
}
.grid-toggle-btn {
width: 36px;
height: 34px;
}
}
/* ── Key bar (rounded chips) ─────────────────────────────────────── */
#keybar {
position: fixed;

View File

@@ -34,6 +34,14 @@ import { mountTimeline, type TimelineHandle } from './timeline.js'
import { createVoiceInput, type VoiceInput } from './voice.js'
import { matchCommand, type VoiceMatchContext } from './voice-commands.js'
import { createApproveConfirm, type ApproveConfirm } from './voice-confirm.js'
import {
type GridLayout,
type GridToggle,
isVisibleIndex,
layoutCapacity,
loadGridLayout,
saveGridLayout,
} from './grid-layout.js'
const TABS_KEY = 'web-terminal:tabs'
const ACTIVE_KEY = 'web-terminal:active'
@@ -59,6 +67,9 @@ interface TabEntry {
autoTitle: string | null // current folder from the terminal title
hasActivity: boolean // inactive tab got output since last viewed
el: HTMLDivElement | null // the .tab element (updated in place)
// Split-grid: the .term-cell wrapper around session.el (header + terminal +
// optional inline-approve footer). One per tab; the grid lays these out.
cell: HTMLDivElement | null
// A4: live timeline handle while this tab's timeline is open (disposed on
// switch/close). Telemetry is NOT cached here — refreshTab reads the single
// source of truth session.telemetry (review #15).
@@ -93,6 +104,11 @@ export class TabApp {
private readonly projects: ProjectsPanel
private homeView: HomeView = 'sessions'
private readonly segControl: HTMLElement
// Split-grid ("watch board") state. Layout persists in localStorage; the
// toolbar toggle (mounted in main.ts) drives setGridLayout and is refreshed
// back via gridToggle. 'single' preserves the original one-pane behavior.
private gridLayout: GridLayout = 'single'
private gridToggle: GridToggle | null = null
// When true, the home chooser is overlaid on top of the open tabs (so you can
// jump back to Sessions/Projects without closing anything). Reset whenever a
// tab is activated. Irrelevant while no tab is open (home shows regardless).
@@ -142,6 +158,10 @@ export class TabApp {
this.segControl = this.buildSegControl()
this.paneHost.appendChild(this.segControl)
// Split-grid: restore the persisted layout (forced to 'single' on screens
// too narrow for a multi-pane board — see loadGridLayout).
this.gridLayout = loadGridLayout()
// v0.7 Walk-away Workbench panels (mounted once; survive tab rebuilds):
this.setupPushToggle() // A1 🔔
this.setupQuickReply() // A3 chips above the key bar
@@ -247,8 +267,7 @@ export class TabApp {
document.body.classList.toggle('home-open', showHome)
if (showHome) {
// Overlay home: hide every terminal pane so the chooser is on top.
for (const t of this.tabs) t.session.hide()
// Overlay home: the chooser is on top; applyLayout hides every pane below.
this.segControl.style.display = 'flex'
this.launcher.setVisible(this.homeView === 'sessions')
this.projects.setVisible(this.homeView === 'projects')
@@ -257,6 +276,10 @@ export class TabApp {
this.launcher.setVisible(false)
this.projects.setVisible(false)
}
// Pane/cell visibility, the grid layout class, focus ring and placeholders
// are all owned by applyLayout — home just decides whether panes show at all.
this.applyLayout(showHome)
}
/** Toggle the home chooser overlay over the open tabs (the ⌂ button). No-op
@@ -630,13 +653,20 @@ export class TabApp {
onClaudeStatus: (status) => {
this.refreshTab(entry)
this.updateApprovalBar()
// Notify when a background tab needs approval (H2/H4).
if (status === 'waiting' && this.tabs.indexOf(entry) !== this.activeIndex) {
// Notify only when a tab that needs approval is NOT on screen. In a split
// grid an on-board quadrant is visible and glows amber in place, so an OS
// notification would be redundant — but a pane hidden behind the ⌂ home
// overlay is NOT on screen even if isVisible() (which only knows the
// layout) would say so, so factor in homeForced too.
const onScreen = !this.homeForced && this.isVisible(this.tabs.indexOf(entry))
if (status === 'waiting' && !onScreen) {
this.notify(entry)
}
},
// B2: telemetry is the single source of truth on the session; just re-render.
onTelemetry: () => this.refreshTab(entry),
// Split-grid: clicking anywhere in this pane makes it the focused quadrant.
onFocus: () => this.setFocused(this.tabs.indexOf(entry)),
})
entry = {
session,
@@ -644,9 +674,20 @@ export class TabApp {
autoTitle: null,
hasActivity: false,
el: null,
cell: null,
timelineHandle: null,
}
this.paneHost.appendChild(session.el)
// Wrap the pane in a grid cell (header + terminal + optional inline-approve).
// The header is hidden by CSS in single mode, so the classic one-pane look is
// unchanged; in a grid it labels each quadrant and carries the focus ring.
const cell = document.createElement('div')
cell.className = 'term-cell'
cell.style.display = 'none'
const head = this.buildCellHead()
head.addEventListener('pointerdown', () => this.setFocused(this.tabs.indexOf(entry)))
cell.append(head, session.el)
entry.cell = cell
this.paneHost.appendChild(cell)
this.tabs.push(entry)
session.applyTheme(THEMES[this.settings.theme] ?? THEMES['dark']!, this.settings.fontSize)
session.connect()
@@ -697,13 +738,24 @@ export class TabApp {
activate(i: number): void {
if (i < 0 || i >= this.tabs.length) return
// Board-aware focus: in a split grid the focused pane must be ON the board,
// else keybar/voice/approval would target an invisible (display:none) session
// with no focus ring. Pull an off-board tab onto the last slot first, then
// resolve its new index. (Single mode has no board, so this is a no-op.)
if (this.gridLayout !== 'single' && !this.isVisible(i)) {
const entry = this.tabs[i]
this.moveTab(i, layoutCapacity(this.gridLayout) - 1)
i = entry ? this.tabs.indexOf(entry) : layoutCapacity(this.gridLayout) - 1
}
this.maybeAskNotify() // first switch is a user gesture — request notif permission
this.homeForced = false // showing a terminal dismisses any home overlay
this.activeIndex = i
const entry = this.tabs[i]
if (entry) entry.hasActivity = false // viewing clears the unread dot
this.tabs.forEach((t, idx) => (idx === i ? t.session.show() : t.session.hide()))
this.tabs.forEach((t) => this.refreshTab(t)) // in-place class/text update, no rebuild
// Pane show/hide + focus is owned by applyLayout (via updateHomeView): in
// single mode only the active pane shows; in a grid the first-N show and the
// active one is the focused quadrant.
this.updateHomeView() // hide the seg control + home panels now a tab is active
this.updateApprovalBar()
if (this.timelineOpen) this.openTimelineForActive() // A4: follow the active session
@@ -714,7 +766,8 @@ export class TabApp {
if (i < 0 || i >= this.tabs.length) return
const [entry] = this.tabs.splice(i, 1)
entry?.timelineHandle?.dispose() // A4: stop polling for the closed tab
entry?.session.dispose()
entry?.session.dispose() // removes session.el (the .term-pane)
entry?.cell?.remove() // also drop the grid cell wrapper
if (this.editingIndex === i) this.editingIndex = -1
if (this.tabs.length === 0) {
// v0.5: closing the last tab returns to the home screen — no auto-blank tab.
@@ -824,12 +877,216 @@ export class TabApp {
new Notification(`Claude needs you — ${title}`, { body: 'Waiting for approval' })
}
/* ── split-grid (watch board) ────────────────────────────────────── */
/** Whether tab `idx` is on-screen (the focused pane in single mode; any of the
* first-N in a grid). Also gates OS-notification suppression. */
private isVisible(idx: number): boolean {
return isVisibleIndex(idx, this.activeIndex, this.tabs.length, this.gridLayout)
}
/** Make tab `idx` the focused pane (via the board-aware activate). */
private setFocused(idx: number): void {
if (idx < 0 || idx >= this.tabs.length) return
if (idx === this.activeIndex) return // already focused — avoid churn
this.activate(idx) // activate() pulls an off-board tab onto the grid first
}
/** The current split-grid layout (for the toolbar toggle). */
getGridLayout(): GridLayout {
return this.gridLayout
}
/** Switch layout (from the toolbar toggle). Persists, keeps the focused pane
* on-board under a smaller capacity, then re-renders. */
setGridLayout(layout: GridLayout): void {
if (layout === this.gridLayout) {
this.gridToggle?.refresh()
return
}
this.gridLayout = layout
saveGridLayout(layout)
// Re-activate the focused pane under the new layout: activate() is board-aware,
// so if the capacity shrank and the pane is now off-board it gets pulled on;
// otherwise it's just a re-render. With no active tab (home), re-apply the class.
if (this.activeIndex >= 0 && this.activeIndex < this.tabs.length) {
this.activate(this.activeIndex)
} else {
this.updateHomeView()
}
this.gridToggle?.refresh()
}
/** Register the toolbar toggle so setGridLayout can re-sync its pressed state. */
setGridToggle(toggle: GridToggle): void {
this.gridToggle = toggle
}
/** Re-assert every VISIBLE pane's size (latest-writer-wins) when this device
* regains focus — in a grid all visible quadrants reclaim size, not just the
* focused one. */
refitVisible(): void {
this.tabs.forEach((entry, idx) => {
if (this.isVisible(idx)) entry.session.refit()
})
}
/** Show/hide panes, set the grid class, order cells, and render placeholders.
* Single source of pane visibility (called via updateHomeView). */
private applyLayout(showHome: boolean): void {
const layout: GridLayout = showHome ? 'single' : this.gridLayout
this.paneHost.classList.remove('lay-single', 'lay-split-2', 'lay-grid-4')
this.paneHost.classList.add(`lay-${layout}`)
const cap = layoutCapacity(this.gridLayout)
const visibleCount = showHome ? 0 : Math.min(this.tabs.length, cap)
this.tabs.forEach((entry, idx) => {
const cell = entry.cell
if (!cell) return
const visible = !showHome && this.isVisible(idx)
cell.style.order = String(idx) // grid places visible cells in tab order
if (visible) {
cell.style.display = 'flex'
entry.session.show({ focus: idx === this.activeIndex })
} else {
cell.style.display = 'none'
entry.session.hide()
}
this.renderCell(entry)
})
// Fill empty grid slots (fewer sessions than the layout holds) with a
// "+ New session" placeholder so the board reads as a fixed N-up grid.
const placeholders =
showHome || this.gridLayout === 'single' ? 0 : Math.max(0, cap - visibleCount)
this.renderPlaceholders(placeholders, visibleCount)
}
/** Replace the placeholder cells with `count` new ones ordered after the panes. */
private renderPlaceholders(count: number, startOrder: number): void {
this.paneHost.querySelectorAll('.term-cell.slot-empty').forEach((e) => e.remove())
for (let i = 0; i < count; i++) {
const slot = document.createElement('div')
slot.className = 'term-cell slot-empty'
slot.style.order = String(startOrder + i)
const btn = document.createElement('button')
btn.type = 'button'
btn.className = 'slot-new'
const plus = document.createElement('b')
plus.textContent = '+'
const label = document.createElement('span')
label.textContent = 'New session'
btn.append(plus, label)
btn.addEventListener('click', () => this.newTab())
slot.appendChild(btn)
this.paneHost.appendChild(slot)
}
}
/** Build a cell header (name + Claude-status chip). Hidden by CSS in single mode. */
private buildCellHead(): HTMLDivElement {
const head = document.createElement('div')
head.className = 'cell-head'
const name = document.createElement('span')
name.className = 'cell-name'
const status = document.createElement('span')
status.className = 'cell-status'
head.append(name, status)
return head
}
/** Sync one cell's header text, focus ring, pending glow, and inline approve. */
private renderCell(entry: TabEntry): void {
const cell = entry.cell
if (!cell) return
const idx = this.tabs.indexOf(entry)
const focused = idx === this.activeIndex
const grid = this.gridLayout !== 'single' && !this.homeForced && this.tabs.length > 0
cell.classList.toggle('focused', grid && focused)
cell.classList.toggle('pending', grid && entry.session.pendingApproval)
const nameEl = cell.querySelector('.cell-name')
if (nameEl) nameEl.textContent = this.displayTitle(entry, idx)
const chip = cell.querySelector<HTMLElement>('.cell-status')
if (chip) {
const cs = entry.session.claudeStatus
chip.className = `cell-status cs-${cs}`
const glyph = claudeIcon(cs)
chip.textContent = cs === 'unknown' ? '' : glyph ? `${glyph} ${cs}` : cs
}
this.renderInlineApprove(entry, cell, idx, focused, grid)
}
/** Inline ✓/✗ footer for a visible NON-focused quadrant with a simple 'tool'
* gate. The focused pane uses the global approval bar (which also handles the
* 3-choice 'plan' gate); a non-focused 'plan' gate just glows amber — click to
* focus and resolve via the bar. */
private renderInlineApprove(
entry: TabEntry,
cell: HTMLDivElement,
idx: number,
focused: boolean,
grid: boolean,
): void {
const session = entry.session
const wantInline =
grid &&
!focused &&
this.isVisible(idx) &&
session.pendingApproval &&
session.pendingGate === 'tool'
let footer = cell.querySelector<HTMLElement>('.cell-approve')
if (!wantInline) {
footer?.remove()
return
}
if (!footer) {
footer = document.createElement('div')
footer.className = 'cell-approve'
footer.addEventListener('pointerdown', (e) => e.stopPropagation()) // don't refocus
const label = document.createElement('span')
label.className = 'cell-approve-label'
const btns = document.createElement('span')
btns.className = 'cell-approve-btns'
const yes = document.createElement('button')
yes.type = 'button'
yes.className = 'cell-approve-yes'
yes.textContent = '✓'
yes.title = 'Approve'
yes.addEventListener('click', () => {
session.approve()
this.updateApprovalBar()
this.renderCell(entry)
})
const no = document.createElement('button')
no.type = 'button'
no.className = 'cell-approve-no'
no.textContent = '✗'
no.title = 'Reject'
no.addEventListener('click', () => {
session.reject()
this.updateApprovalBar()
this.renderCell(entry)
})
btns.append(yes, no)
footer.append(label, btns)
cell.appendChild(footer)
}
const label = footer.querySelector('.cell-approve-label')
if (label) label.textContent = session.pendingTool ? `Approve ${session.pendingTool}?` : 'Approve?'
}
/* ── rendering ───────────────────────────────────────────────────── */
/** In-place update of one tab's classes/label/dot/gauge (never destroys DOM).
* Single `refreshTab` owner for status dot, stuck badge (A5) and telemetry
* gauge (B2), per SP10/M4. */
private refreshTab(entry: TabEntry): void {
// Keep the grid cell (header text, focus ring, pending glow, inline approve)
// in sync too — independent of whether the tab-bar element exists yet.
this.renderCell(entry)
const el = entry.el
if (!el) return
const idx = this.tabs.indexOf(entry)

View File

@@ -66,6 +66,9 @@ export interface TerminalSessionOpts {
/** Optional: fired when new statusLine telemetry arrives (B2). Single source of
* truth — T-tabs reads session.telemetry via the getter, not its own copy. */
onTelemetry?: (telemetry: StatusTelemetry) => void
/** Optional: fired on a pointerdown anywhere in this pane (split-grid focus).
* Lets TabApp move the focused-pane (activeIndex) to the clicked quadrant. */
onFocus?: () => void
/** Optional: spawn a NEW session in this directory ("new tab here", M6). */
cwd?: string
/** Optional: type this once the new session's shell is ready (O2 resume). */
@@ -85,6 +88,7 @@ export class TerminalSession {
private readonly onStatus: ((status: SessionStatus) => void) | undefined
private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined
private readonly onTelemetry: ((telemetry: StatusTelemetry) => void) | undefined
private readonly onFocus: (() => void) | undefined
private readonly spawnCwd: string | undefined
private readonly initialInput: string | undefined
private initialSent = false
@@ -121,12 +125,16 @@ export class TerminalSession {
this.onStatus = opts.onStatus
this.onClaudeStatus = opts.onClaudeStatus
this.onTelemetry = opts.onTelemetry
this.onFocus = opts.onFocus
this.spawnCwd = opts.cwd
this.initialInput = opts.initialInput
this.el = document.createElement('div')
this.el.className = 'term-pane'
this.el.style.display = 'none'
// Split-grid: a pointerdown anywhere in the pane makes it the focused quadrant.
// Capture phase so it fires before xterm's own handling; never blocks input.
this.el.addEventListener('pointerdown', () => this.onFocus?.(), { capture: true })
this.term = new Terminal({
scrollback: 5000,
@@ -403,13 +411,18 @@ export class TerminalSession {
}
}
/** Make this pane visible, fit it, and focus it. */
show(): void {
/**
* Make this pane visible and fit it. Focuses the terminal unless `focus: false`
* — in a split grid several panes are shown at once, so only the focused
* quadrant should grab keyboard focus (four panes calling focus() would fight).
*/
show(opts?: { focus?: boolean }): void {
this.el.style.display = 'block'
const shouldFocus = opts?.focus !== false
requestAnimationFrame(() => {
const dims = this.safefit()
if (dims !== null) this.sendResize(dims.cols, dims.rows)
this.term.focus()
if (shouldFocus) this.term.focus()
})
}