diff --git a/src/actions/launch.ts b/src/actions/launch.ts index db87844..be05019 100644 --- a/src/actions/launch.ts +++ b/src/actions/launch.ts @@ -65,7 +65,14 @@ export async function doLaunch() { targetTabId = createNewGridTab() // Rename to match the picker tab number const tab = app.gridTabs.find(t => t.id === targetTabId) - if (tab) tab.name = `Tab ${tabNum}` + if (tab) { + tab.name = `Tab ${tabNum}` + app.gridTabs.sort((a, b) => { + const na = parseInt(a.name.replace(/\D/g, "")) || 0 + const nb = parseInt(b.name.replace(/\D/g, "")) || 0 + return na - nb + }) + } } const termW = process.stdout.columns || 120 diff --git a/src/components/direct-grid.ts b/src/components/direct-grid.ts index 9125858..340d0d4 100644 --- a/src/components/direct-grid.ts +++ b/src/components/direct-grid.ts @@ -85,6 +85,8 @@ export class DirectGridRenderer { // Tab bar hit-test regions (col ranges for each tab) private tabBarHitRegions: { tabId: number, startCol: number, endCol: number }[] = [] private tabBarAddBtnCol = -1 + // Pane list hit-test regions (row 2) + private paneListHitRegions: { tabId: number, paneIndex: number, startCol: number, endCol: number }[] = [] constructor(rawWrite: (s: string) => boolean) { this.writeRaw = rawWrite @@ -279,6 +281,10 @@ export class DirectGridRenderer { return this.tabPanes.get(tabId)?.length ?? 0 } + getTabPanes(tabId: number): readonly GridPaneInfo[] { + return this.tabPanes.get(tabId) ?? [] + } + hasIdleInTab(tabId: number): boolean { const panes = this.tabPanes.get(tabId) if (!panes) return false @@ -286,7 +292,7 @@ export class DirectGridRenderer { } // Check if a click hit a button on the top border. Returns action + pane index. - checkButtonClick(col: number, row: number): { action: "max" | "min" | "sel" | "tab" | "newtab", paneIndex: number, tabId?: number } | null { + checkButtonClick(col: number, row: number): { action: "max" | "min" | "sel" | "tab" | "newtab" | "panefocus", paneIndex: number, tabId?: number } | null { // Tab bar check (row 1) if (row === 1) { for (const region of this.tabBarHitRegions) { @@ -299,6 +305,15 @@ export class DirectGridRenderer { } return null } + // Pane list check (row 2) + if (row === 2) { + for (const region of this.paneListHitRegions) { + if (col >= region.startCol && col <= region.endCol) { + return { action: "panefocus", paneIndex: region.paneIndex, tabId: region.tabId } + } + } + return null + } const indicesToCheck = this.isExpanded ? [this._expandedIndex] : this.panes.map((_, i) => i) for (const i of indicesToCheck) { @@ -410,35 +425,7 @@ export class DirectGridRenderer { } focusByDirection(dir: "up" | "down" | "left" | "right") { - if (this.isSoftExpanded) { - // In soft expand: left/right toggles between expanded and strips - const sei = this._softExpandIndex - const strips = this.panes.map((_, i) => i).filter(i => i !== sei) - if (strips.length === 0) return - - if (dir === "left" || dir === "right") { - if (dir === "right" && this._focusIndex === sei) { - this.setFocus(strips[0]!) - } else if (dir === "left" && this._focusIndex !== sei) { - this.setFocus(sei) - } else { - const curStripIdx = strips.indexOf(this._focusIndex) - const nextIdx = (curStripIdx + 1) % strips.length - this.setFocus(strips[nextIdx]!) - } - return - } - // Up/down navigates within strips - if (this._focusIndex === sei) { - this.setFocus(strips[0]!) - return - } - const curStripIdx = strips.indexOf(this._focusIndex) - if (dir === "down") this.setFocus(strips[(curStripIdx + 1) % strips.length]!) - else this.setFocus(strips[(curStripIdx - 1 + strips.length) % strips.length]!) - return - } - + // Weighted grid keeps same positions — use standard grid nav for both modes const n = this.panes.length if (n <= 1) return const { cols } = this.calcGrid(n) @@ -477,12 +464,13 @@ export class DirectGridRenderer { if (n === 0) return false const termW = process.stdout.columns || 120 const termH = process.stdout.rows || 40 + const chromeTop = 4 const { cols } = this.calcGrid(n) const rows = Math.ceil(n / cols) const cellW = Math.floor(termW / cols) - const cellH = Math.floor((termH - 2) / rows) + const cellH = Math.floor((termH - chromeTop - 1) / rows) const gc = Math.floor((col - 1) / cellW) - const gr = Math.floor((row - 2) / cellH) + const gr = Math.floor((row - chromeTop) / cellH) const idx = gr * cols + gc if (idx >= 0 && idx < n) { this.setFocus(idx) @@ -508,12 +496,13 @@ export class DirectGridRenderer { if (n === 0) return -1 const termW = process.stdout.columns || 120 const termH = process.stdout.rows || 40 + const chromeTop = 4 const { cols } = this.calcGrid(n) const rows = Math.ceil(n / cols) const cellW = Math.floor(termW / cols) - const cellH = Math.floor((termH - 2) / rows) + const cellH = Math.floor((termH - chromeTop - 1) / rows) const gc = Math.floor((col - 1) / cellW) - const gr = Math.floor((row - 2) / cellH) + const gr = Math.floor((row - chromeTop) / cellH) const idx = gr * cols + gc return (idx >= 0 && idx < n) ? idx : -1 } @@ -530,7 +519,10 @@ export class DirectGridRenderer { // Tab bar (row 1) out += this.drawTabBar(termW) - // Header (row 2) + // Pane list (row 2) + out += this.drawPaneList(termW) + + // Header (row 3) const n = this.panes.length const fi = this._focusIndex + 1 let headerLeft: string, headerRight: string @@ -547,7 +539,7 @@ export class DirectGridRenderer { headerLeft = ` ${BOLD}cladm grid${RESET} — ${n} sessions │ focus: ${fi}/${n}` headerRight = `${DIM}shift+arrows nav │ click ${BOLD}[MAX]${RESET}${DIM} expand │ ctrl+space picker │ ctrl+w close${RESET}` } - out += `\x1b[2;1H\x1b[${termW}X${headerLeft} ${headerRight}` + out += `\x1b[3;1H\x1b[${termW}X${headerLeft} ${headerRight}` // Pane borders + titles if (this.isExpanded) { @@ -623,6 +615,45 @@ export class DirectGridRenderer { return out } + private drawPaneList(termW: number): string { + this.paneListHitRegions = [] + let out = `\x1b[2;1H\x1b[${termW}X ` + let col = 3 + + // Show panes across all tabs, grouped by tab + for (const tab of app.gridTabs) { + const tabPanes = this.tabPanes.get(tab.id) ?? [] + if (tabPanes.length === 0) continue + + for (let pi = 0; pi < tabPanes.length; pi++) { + const pane = tabPanes[pi]! + const isFocused = this._activeTabId === tab.id && this._focusIndex === pi + const name = pane.session.projectName + const short = name.length > 14 ? name.slice(0, 12) + "…" : name + const color = getColor(pane.session.colorIndex) + + const startCol = col + if (isFocused) { + out += `${hexFg(color)}${BOLD}${short}${RESET}` + } else { + out += `${DIM}${short}${RESET}` + } + col += short.length + this.paneListHitRegions.push({ tabId: tab.id, paneIndex: pi, startCol, endCol: col - 1 }) + + if (pi < tabPanes.length - 1) { + out += `${DIM} · ${RESET}` + col += 3 + } + } + + out += `${DIM} │ ${RESET}` + col += 5 + } + + return out + } + private drawPaneBorder(index: number): string { const pane = this.panes[index]! const dp = pane.directPane @@ -786,7 +817,7 @@ export class DirectGridRenderer { const n = count ?? this.panes.length const termW = process.stdout.columns || 120 const termH = process.stdout.rows || 40 - const chromeTop = 3 // row 1 = tab bar, row 2 = header, content starts row 3 + const chromeTop = 4 // row 1 = tab bar, row 2 = pane list, row 3 = header, content starts row 4 const { cols, rows } = this.calcGrid(n) const cellW = Math.floor(termW / cols) const cellH = Math.floor((termH - chromeTop - 1) / rows) // -1 for footer @@ -812,7 +843,7 @@ export class DirectGridRenderer { repositionAll() { const termW = process.stdout.columns || 120 const termH = process.stdout.rows || 40 - const chromeTop = 3 + const chromeTop = 4 if (this.isExpanded) { // Fullscreen: expanded pane gets all space @@ -824,38 +855,49 @@ export class DirectGridRenderer { resizeCapture(pane.session.name, Math.max(contentW, 10), Math.max(contentH, 2)) resetHash(`dp_${pane.session.name}`) } else if (this.isSoftExpanded) { - // Soft expand: 70/30 split + // Weighted grid: focused pane's col/row get 70%, others split the rest const sei = this._softExpandIndex const n = this.panes.length - const availH = termH - chromeTop - 1 // available rows for content + const { cols, rows } = this.calcGrid(n) + const focusCol = sei % cols + const focusRow = Math.floor(sei / cols) const availW = termW + const availH = termH - chromeTop - 1 - // Expanded pane: 70% width, full height - const expandedW = Math.max(Math.floor(availW * 0.7) - 2, 20) - const expandedH = Math.max(availH - 4, 2) - const expandedPane = this.panes[sei]! - expandedPane.directPane.reposition(2, chromeTop + 3, expandedW, expandedH) - resizeSession(expandedPane.session.name, expandedW, expandedH) - resizeCapture(expandedPane.session.name, expandedW, expandedH) - resetHash(`dp_${expandedPane.session.name}`) + // Compute column widths: focused col gets 70%, others split 30% + const colWidths: number[] = [] + const otherCols = cols - 1 + const focusColW = otherCols > 0 ? Math.floor(availW * 0.7) : availW + const otherColW = otherCols > 0 ? Math.floor((availW - focusColW) / otherCols) : 0 + for (let c = 0; c < cols; c++) colWidths.push(c === focusCol ? focusColW : otherColW) - // Strip panes: 30% width, stacked vertically - const strips = this.panes.map((_, i) => i).filter(i => i !== sei) - if (strips.length > 0) { - const stripX = 2 + expandedW + 2 // after expanded pane + border - const stripW = Math.max(availW - expandedW - 4, 10) // remaining width minus borders - const stripCellH = Math.floor(availH / strips.length) + // Compute row heights: focused row gets 70%, others split 30% + const rowHeights: number[] = [] + const otherRows = rows - 1 + const focusRowH = otherRows > 0 ? Math.floor(availH * 0.7) : availH + const otherRowH = otherRows > 0 ? Math.floor((availH - focusRowH) / otherRows) : 0 + for (let r = 0; r < rows; r++) rowHeights.push(r === focusRow ? focusRowH : otherRowH) - for (let si = 0; si < strips.length; si++) { - const pi = strips[si]! - const pane = this.panes[pi]! - const stripH = Math.max(stripCellH - 4, 2) - const stripY = chromeTop + si * stripCellH + 3 - pane.directPane.reposition(stripX, stripY, stripW, stripH) - resizeSession(pane.session.name, stripW, stripH) - resizeCapture(pane.session.name, stripW, stripH) - resetHash(`dp_${pane.session.name}`) - } + // Compute column X offsets + const colX: number[] = [0] + for (let c = 1; c < cols; c++) colX.push(colX[c - 1]! + colWidths[c - 1]!) + + // Compute row Y offsets + const rowY: number[] = [0] + for (let r = 1; r < rows; r++) rowY.push(rowY[r - 1]! + rowHeights[r - 1]!) + + for (let i = 0; i < n; i++) { + const gc = i % cols + const gr = Math.floor(i / cols) + const contentW = Math.max(colWidths[gc]! - 2, 10) + const contentH = Math.max(rowHeights[gr]! - 4, 2) + const screenX = colX[gc]! + 2 + const screenY = chromeTop + rowY[gr]! + 3 + const pane = this.panes[i]! + pane.directPane.reposition(screenX, screenY, contentW, contentH) + resizeSession(pane.session.name, contentW, contentH) + resizeCapture(pane.session.name, contentW, contentH) + resetHash(`dp_${pane.session.name}`) } } else { // Equal grid diff --git a/src/data/monitor.ts b/src/data/monitor.ts index 3facdfb..f9c6a67 100644 --- a/src/data/monitor.ts +++ b/src/data/monitor.ts @@ -312,15 +312,33 @@ export function updateProjectSessions(projects: Project[], sessions: Map() // path → timestamp when first went idle + export function checkTransitions( projects: Project[], prevBusy: Map ): string[] { + const now = Date.now() const transitioned: string[] = [] for (const project of projects) { const prev = prevBusy.get(project.path) || 0 - if (prev > 0 && project.busySessions === 0 && project.activeSessions > 0) { - transitioned.push(project.name) + const isIdle = project.busySessions === 0 && project.activeSessions > 0 + + if (prev > 0 && isIdle && !pendingIdle.has(project.path)) { + // Just transitioned busy→idle — start the delay timer + pendingIdle.set(project.path, now) + } + + if (pendingIdle.has(project.path)) { + if (!isIdle) { + // Went busy again — false alarm, cancel + pendingIdle.delete(project.path) + } else if (now - pendingIdle.get(project.path)! >= IDLE_SOUND_DELAY_MS) { + // Confirmed idle for 10+ seconds + transitioned.push(project.name) + pendingIdle.delete(project.path) + } } } return transitioned diff --git a/src/grid/view-switch.ts b/src/grid/view-switch.ts index 291dee9..2193b92 100644 --- a/src/grid/view-switch.ts +++ b/src/grid/view-switch.ts @@ -48,6 +48,11 @@ export function createNewGridTab(): number { const tabId = app.nextTabId++ const tab = { id: tabId, name: `Tab ${tabId}` } app.gridTabs.push(tab) + app.gridTabs.sort((a, b) => { + const na = parseInt(a.name.replace(/\D/g, "")) || 0 + const nb = parseInt(b.name.replace(/\D/g, "")) || 0 + return na - nb + }) if (!app.directGrid) { app.directGrid = new DirectGridRenderer(app.rawStdoutWrite) diff --git a/src/index.ts b/src/index.ts index 09bd231..cff277f 100755 --- a/src/index.ts +++ b/src/index.ts @@ -87,6 +87,12 @@ async function main() { flexShrink: 0, }) + app.paneListText = new TextRenderable(app.renderer, { + width: "100%", + height: 1, + flexShrink: 0, + }) + app.headerText = new TextRenderable(app.renderer, { width: "100%", height: 1, @@ -155,6 +161,7 @@ async function main() { }) app.mainBox.add(app.tabBarText) + app.mainBox.add(app.paneListText) app.mainBox.add(app.headerText) app.mainBox.add(app.colHeaderText) app.mainBox.add(app.listBox) diff --git a/src/input/handlers.ts b/src/input/handlers.ts index e058593..35ef085 100644 --- a/src/input/handlers.ts +++ b/src/input/handlers.ts @@ -533,12 +533,18 @@ function processGridInput(str: string) { } } else if (btn?.action === "newtab") createNewGridTab() + else if (btn?.action === "panefocus" && btn.tabId !== undefined) { + // Click on pane name in pane list → switch to that tab and focus the pane + switchToGridTab(btn.tabId) + dg.setFocus(btn.paneIndex) + if (app.clickExpand) dg.softExpandPane(btn.paneIndex) + } else { // Pane body click if (app.clickExpand && !dg.isExpanded) { const clickedIdx = dg.getPaneIndexAtClick(me.col, me.row) - if (clickedIdx >= 0) { - dg.toggleSoftExpand(clickedIdx) + if (clickedIdx >= 0 && clickedIdx !== dg.focusIndex) { + dg.softExpandPane(clickedIdx) } } else { dg.focusByClick(me.col, me.row) diff --git a/src/lib/state.ts b/src/lib/state.ts index 65f2fb8..eee9487 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -52,6 +52,7 @@ export const app = { renderer: null as unknown as CliRenderer, headerText: null as unknown as TextRenderable, tabBarText: null as unknown as TextRenderable, + paneListText: null as unknown as TextRenderable, colHeaderText: null as unknown as TextRenderable, listBox: null as unknown as ScrollBoxRenderable, bottomRow: null as unknown as BoxRenderable, diff --git a/src/ui/panels.ts b/src/ui/panels.ts index fd9ce28..ce2218c 100644 --- a/src/ui/panels.ts +++ b/src/ui/panels.ts @@ -69,6 +69,44 @@ export function applySortMode() { // ─── Tab bar ───────────────────────────────────────────────────────── +const PANE_COLORS = [ + "#7aa2f7", "#9ece6a", "#e0af68", "#f7768e", "#bb9af7", + "#7dcfff", "#ff9e64", "#c0caf5", "#73daca", "#b4f9f8", +] + +export function updatePaneList() { + if (!app.paneListText) return + if (!app.directGrid || app.gridTabs.length === 0) { + app.paneListText.content = "" + return + } + + let content = t` ` + let first = true + for (const tab of app.gridTabs) { + const tabPanes = app.directGrid.getTabPanes(tab.id) + if (tabPanes.length === 0) continue + + for (let pi = 0; pi < tabPanes.length; pi++) { + const pane = tabPanes[pi]! + const name = pane.session.projectName + const short = name.length > 14 ? name.slice(0, 12) + "…" : name + const isFocused = app.directGrid!.activeTabId === tab.id && app.directGrid!.focusIndex === pi + + if (!first) content = t`${content}${dim(" · ")}` + if (isFocused) { + content = t`${content}${bold(short)}` + } else { + content = t`${content}${dim(short)}` + } + first = false + } + content = t`${content}${dim(" │ ")}` + first = true + } + app.paneListText.content = content +} + export function updateTabBar() { if (!app.tabBarText) return @@ -363,6 +401,7 @@ function clearChildren(box: { getChildren(): { id: string }[]; remove(id: string export function updateAll() { if (app.destroyed) return updateTabBar() + updatePaneList() updateHeader() rebuildList() updateBottomPanel()