From 9e424c192aeea28b08ad453755b26d1f11ef5f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 28 Feb 2026 10:45:25 +0000 Subject: [PATCH] feat: add tabbed grids, click-to-expand, and keybind cleanup Introduces per-tab pane management with independent focus/expansion state, a persistent tab bar visible in both picker and grid modes, and a 70/30 soft-expand layout triggered by clicking pane bodies. Removes legacy toggle keybinds (Ctrl+^ and Ctrl+`) leaving only Ctrl+Space. New keybinds: Ctrl+T new tab, Alt+1-9 switch tab, Alt+n/p cycle tabs, Ctrl+E toggle click-expand, Ctrl+W auto-removes empty tabs. Co-Authored-By: Claude Opus 4.6 --- src/actions/launch.ts | 14 +- src/components/direct-grid.ts | 495 ++++++++++++++++++++++++++++------ src/grid/view-switch.ts | 37 ++- src/index.ts | 7 + src/input/handlers.ts | 194 +++++++++++-- src/input/parser.ts | 6 +- src/lib/state.ts | 13 + src/ui/panels.ts | 35 ++- 8 files changed, 692 insertions(+), 109 deletions(-) diff --git a/src/actions/launch.ts b/src/actions/launch.ts index 30e0678..b3f89c5 100644 --- a/src/actions/launch.ts +++ b/src/actions/launch.ts @@ -1,6 +1,6 @@ import { app } from "../lib/state" import { updateAll, rebuildDisplayRows } from "../ui/panels" -import { ensureGridView } from "../grid/view-switch" +import { ensureGridView, createNewGridTab, switchToGridTab } from "../grid/view-switch" import { loadSessions } from "../data/sessions" import { createSession } from "../pty/session-manager" @@ -43,11 +43,19 @@ export async function doLaunch() { if (items.length === 0) return + // Determine target tab: use active grid tab or create a new one + let targetTabId: number + if (app.viewMode === "grid" && app.directGrid && app.gridTabs.length > 0) { + targetTabId = app.directGrid.activeTabId + } else { + targetTabId = createNewGridTab() + } + ensureGridView() const termW = process.stdout.columns || 120 const termH = process.stdout.rows || 40 - const totalPanes = items.length + (app.directGrid?.paneCount || 0) + const totalPanes = items.length + (app.directGrid?.getTabPaneCount(targetTabId) || 0) const cols = totalPanes <= 1 ? 1 : totalPanes <= 2 ? 2 : totalPanes <= 4 ? 2 : 3 const rows = Math.ceil(totalPanes / cols) const paneW = Math.max(Math.floor(termW / cols) - 2, 20) @@ -62,7 +70,7 @@ export async function doLaunch() { width: paneW, height: paneH, }) - await app.directGrid!.addPane(session) + await app.directGrid!.addPane(session, targetTabId) } app.selectedProjects.clear() diff --git a/src/components/direct-grid.ts b/src/components/direct-grid.ts index be7e035..9125858 100644 --- a/src/components/direct-grid.ts +++ b/src/components/direct-grid.ts @@ -5,6 +5,7 @@ import { DirectPane } from "./direct-pane" import { startCapture, stopCapture, resizeCapture, resetHash, getLatestFrame, scrollPane, getScrollOffset } from "../pty/capture" import { writeToSession, resizeSession, killSession, type PtySession } from "../pty/session-manager" +import { app, type GridTab } from "../lib/state" export type PaneStatus = "busy" | "idle" | null @@ -13,6 +14,7 @@ export interface GridPaneInfo { directPane: DirectPane status: PaneStatus statusSince: number + tabId: number } const PROJECT_COLORS = [ @@ -31,6 +33,13 @@ function hexFg(hex: string): string { return `\x1b[38;2;${r};${g};${b}m` } +function hexBg(hex: string): string { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return `\x1b[48;2;${r};${g};${b}m` +} + const RESET = "\x1b[0m" const BOLD = "\x1b[1m" const DIM = "\x1b[2m" @@ -41,6 +50,11 @@ const SYNC_START = "\x1b[?2026h" const SYNC_END = "\x1b[?2026l" const CLEAR = "\x1b[2J\x1b[H" +const CYAN_FG = hexFg("#7dcfff") +const YELLOW_FG = hexFg("#e0af68") +const TAB_ACTIVE_BG = hexBg("#1a1b26") +const TAB_DIM_BG = hexBg("#16161e") + function fmtElapsed(sinceMs: number): string { if (!sinceMs) return "" const sec = Math.floor((Date.now() - sinceMs) / 1000) @@ -55,19 +69,54 @@ function fmtElapsed(sinceMs: number): string { } export class DirectGridRenderer { - private panes: GridPaneInfo[] = [] - private _focusIndex = 0 + // Per-tab state + private tabPanes = new Map() + private tabFocus = new Map() + private tabExpanded = new Map() // fullscreen expand + private tabSoftExpand = new Map() // soft expand (70/30) + private _activeTabId = -1 + private writeRaw: (s: string) => boolean private flashTimers = new Map>() private titleTimer: ReturnType | null = null private running = false private _selectMode = false - private _expandedIndex = -1 // -1 = grid view, >=0 = expanded pane index + + // Tab bar hit-test regions (col ranges for each tab) + private tabBarHitRegions: { tabId: number, startCol: number, endCol: number }[] = [] + private tabBarAddBtnCol = -1 constructor(rawWrite: (s: string) => boolean) { this.writeRaw = rawWrite } + // ─── Active tab pane accessors ────────────────────────── + + private get panes(): GridPaneInfo[] { + return this.tabPanes.get(this._activeTabId) ?? [] + } + + private get _focusIndex(): number { + return this.tabFocus.get(this._activeTabId) ?? 0 + } + private set _focusIndex(v: number) { + this.tabFocus.set(this._activeTabId, v) + } + + private get _expandedIndex(): number { + return this.tabExpanded.get(this._activeTabId) ?? -1 + } + private set _expandedIndex(v: number) { + this.tabExpanded.set(this._activeTabId, v) + } + + private get _softExpandIndex(): number { + return this.tabSoftExpand.get(this._activeTabId) ?? -1 + } + private set _softExpandIndex(v: number) { + this.tabSoftExpand.set(this._activeTabId, v) + } + // ─── Lifecycle ───────────────────────────────────────── start() { @@ -82,9 +131,11 @@ export class DirectGridRenderer { if (this.titleTimer) { clearInterval(this.titleTimer); this.titleTimer = null } for (const timer of this.flashTimers.values()) clearInterval(timer) this.flashTimers.clear() - for (const p of this.panes) { - p.directPane.detach() - stopCapture(p.session.name) + for (const [, panes] of this.tabPanes) { + for (const p of panes) { + p.directPane.detach() + stopCapture(p.session.name) + } } this.writeRaw(SHOW_CURSOR) } @@ -92,20 +143,20 @@ export class DirectGridRenderer { pause() { this.running = false if (this.titleTimer) { clearInterval(this.titleTimer); this.titleTimer = null } - // Detach frame listeners (stops rendering) but keep captures alive - for (const p of this.panes) p.directPane.detach() + for (const [, panes] of this.tabPanes) { + for (const p of panes) p.directPane.detach() + } } resume() { this.running = true this.writeRaw(HIDE_CURSOR + CLEAR) - // Reattach frame listeners and redraw - for (let i = 0; i < this.panes.length; i++) { - const p = this.panes[i] - const dp = p.directPane + const panes = this.panes + for (let i = 0; i < panes.length; i++) { + const p = panes[i]! const idx = i - dp.attach(p.session.name) - dp.onFrame = (lines) => { + p.directPane.attach(p.session.name) + p.directPane.onFrame = (lines) => { if (!this.running) return this.drawPane(idx, lines) } @@ -118,21 +169,24 @@ export class DirectGridRenderer { get focusIndex() { return this._focusIndex } get paneCount() { return this.panes.length } + get totalPaneCount() { let n = 0; for (const [, p] of this.tabPanes) n += p.length; return n } get focusedPane(): GridPaneInfo | null { return this.panes[this._focusIndex] ?? null } get selectMode() { return this._selectMode } get isExpanded() { return this._expandedIndex >= 0 } + get isSoftExpanded() { return this._softExpandIndex >= 0 } + get activeTabId() { return this._activeTabId } enterSelectMode() { - if (!this.isExpanded) return // Only allow in expanded mode + if (!this.isExpanded) return this._selectMode = true - this.writeRaw("\x1b[?1000l\x1b[?1006l") // Disable mouse reporting + this.writeRaw("\x1b[?1000l\x1b[?1006l") this.writeRaw(SHOW_CURSOR) this.drawChrome() } exitSelectMode() { this._selectMode = false - this.writeRaw("\x1b[?1000h\x1b[?1006h") // Re-enable mouse reporting + this.writeRaw("\x1b[?1000h\x1b[?1006h") this.writeRaw(HIDE_CURSOR) this.drawChrome() } @@ -141,6 +195,7 @@ export class DirectGridRenderer { const idx = index ?? this._focusIndex if (idx < 0 || idx >= this.panes.length) return this._expandedIndex = idx + this._softExpandIndex = -1 this._focusIndex = idx this.repositionAll() } @@ -148,11 +203,103 @@ export class DirectGridRenderer { collapsePane() { if (this._selectMode) this.exitSelectMode() this._expandedIndex = -1 + this._softExpandIndex = -1 this.repositionAll() } + // ─── Soft Expand ────────────────────────────────────── + + softExpandPane(index: number) { + if (index < 0 || index >= this.panes.length) return + this._softExpandIndex = index + this._focusIndex = index + this.repositionAll() + } + + softCollapsePane() { + this._softExpandIndex = -1 + this.repositionAll() + } + + toggleSoftExpand(index: number) { + if (this._softExpandIndex === index) this.softCollapsePane() + else this.softExpandPane(index) + } + + // ─── Tab management ─────────────────────────────────── + + addTab(tab: GridTab) { + this.tabPanes.set(tab.id, []) + this.tabFocus.set(tab.id, 0) + this.tabExpanded.set(tab.id, -1) + this.tabSoftExpand.set(tab.id, -1) + } + + removeTab(tabId: number) { + const panes = this.tabPanes.get(tabId) + if (panes) { + for (const p of panes) { + p.directPane.detach() + stopCapture(p.session.name) + killSession(p.session.name) + this.clearFlash(p.session.name) + } + } + this.tabPanes.delete(tabId) + this.tabFocus.delete(tabId) + this.tabExpanded.delete(tabId) + this.tabSoftExpand.delete(tabId) + } + + setActiveTab(tabId: number) { + if (this._activeTabId === tabId) return + // Detach current tab's panes + if (this._activeTabId >= 0) { + for (const p of this.panes) p.directPane.detach() + } + this._activeTabId = tabId + // Reattach new tab's panes + const panes = this.panes + for (let i = 0; i < panes.length; i++) { + const p = panes[i]! + const idx = i + p.directPane.attach(p.session.name) + p.directPane.onFrame = (lines) => { + if (!this.running) return + this.drawPane(idx, lines) + } + } + if (this.running) { + this.writeRaw(CLEAR) + this.repositionAll() + } + } + + getTabPaneCount(tabId: number): number { + return this.tabPanes.get(tabId)?.length ?? 0 + } + + hasIdleInTab(tabId: number): boolean { + const panes = this.tabPanes.get(tabId) + if (!panes) return false + return panes.some(p => p.status === "idle") + } + // Check if a click hit a button on the top border. Returns action + pane index. - checkButtonClick(col: number, row: number): { action: "max" | "min" | "sel", paneIndex: number } | null { + checkButtonClick(col: number, row: number): { action: "max" | "min" | "sel" | "tab" | "newtab", paneIndex: number, tabId?: number } | null { + // Tab bar check (row 1) + if (row === 1) { + for (const region of this.tabBarHitRegions) { + if (col >= region.startCol && col <= region.endCol) { + return { action: "tab", paneIndex: -1, tabId: region.tabId } + } + } + if (this.tabBarAddBtnCol > 0 && col >= this.tabBarAddBtnCol && col <= this.tabBarAddBtnCol + 2) { + return { action: "newtab", paneIndex: -1 } + } + return null + } + const indicesToCheck = this.isExpanded ? [this._expandedIndex] : this.panes.map((_, i) => i) for (const i of indicesToCheck) { const dp = this.panes[i]!.directPane @@ -164,8 +311,6 @@ export class DirectGridRenderer { if (row !== btnRow) continue if (this.isExpanded) { - // Expanded: buttons are [SEL] and [MIN] at top-right - // Layout: ...hz [SEL] hz [MIN] hz tr const minRight = bx + bw - 2 const minLeft = minRight - 4 if (col >= minLeft && col <= minRight) return { action: "min", paneIndex: i } @@ -173,7 +318,6 @@ export class DirectGridRenderer { const selLeft = selRight - 4 if (col >= selLeft && col <= selRight) return { action: "sel", paneIndex: i } } else { - // Grid: button is [MAX] at top-right const btnLeft = bx + bw - 7 const btnRight = bx + bw - 3 if (col >= btnLeft && col <= btnRight) return { action: "max", paneIndex: i } @@ -184,50 +328,67 @@ export class DirectGridRenderer { // ─── Pane management ─────────────────────────────────── - async addPane(session: PtySession): Promise { - const regions = this.calcPaneRegions(this.panes.length + 1) - const idx = this.panes.length - const region = regions[idx]! - - const dp = new DirectPane(region.screenX, region.screenY, region.contentW, region.contentH) - const info: GridPaneInfo = { session, directPane: dp, status: null, statusSince: 0 } - this.panes.push(info) - - // Resize PTY to match content area - resizeSession(session.name, region.contentW, region.contentH) - - // Start capture (reads PTY stdout and pushes frames) - startCapture(session) - - // Subscribe to push frames (must set callback AFTER attach, since attach calls detach which nulls onFrame) - dp.attach(session.name) - dp.onFrame = (lines) => { - if (!this.running) return - this.drawPane(idx, lines) + async addPane(session: PtySession, tabId?: number): Promise { + const tid = tabId ?? this._activeTabId + let panes = this.tabPanes.get(tid) + if (!panes) { + panes = [] + this.tabPanes.set(tid, []) + this.tabFocus.set(tid, 0) + this.tabExpanded.set(tid, -1) + this.tabSoftExpand.set(tid, -1) + panes = this.tabPanes.get(tid)! } - // Reposition all existing panes - this.repositionAll() + const isActive = tid === this._activeTabId + const regions = isActive ? this.calcPaneRegions(panes.length + 1) : [{ screenX: 2, screenY: 5, contentW: 20, contentH: 6 }] + const idx = panes.length + const region = regions[Math.min(idx, regions.length - 1)]! + + const dp = new DirectPane(region.screenX, region.screenY, region.contentW, region.contentH) + const info: GridPaneInfo = { session, directPane: dp, status: null, statusSince: 0, tabId: tid } + panes.push(info) + + resizeSession(session.name, region.contentW, region.contentH) + startCapture(session) + + if (isActive) { + dp.attach(session.name) + dp.onFrame = (lines) => { + if (!this.running) return + this.drawPane(idx, lines) + } + this.repositionAll() + } return info } removePane(sessionName: string) { - const idx = this.panes.findIndex(p => p.session.name === sessionName) - if (idx < 0) return + // Search across all tabs + for (const [tabId, panes] of this.tabPanes) { + const idx = panes.findIndex(p => p.session.name === sessionName) + if (idx < 0) continue - const pane = this.panes[idx]! - pane.directPane.detach() - stopCapture(pane.session.name) - killSession(pane.session.name) - this.clearFlash(sessionName) - this.panes.splice(idx, 1) + const pane = panes[idx]! + pane.directPane.detach() + stopCapture(pane.session.name) + killSession(pane.session.name) + this.clearFlash(sessionName) + panes.splice(idx, 1) - if (this._focusIndex >= this.panes.length) { - this._focusIndex = Math.max(0, this.panes.length - 1) + if (tabId === this._activeTabId) { + const fi = this.tabFocus.get(tabId) ?? 0 + if (fi >= panes.length) this.tabFocus.set(tabId, Math.max(0, panes.length - 1)) + // Reset expand states if they reference removed pane + const ei = this.tabExpanded.get(tabId) ?? -1 + if (ei >= panes.length || ei === idx) this.tabExpanded.set(tabId, -1) + const si = this.tabSoftExpand.get(tabId) ?? -1 + if (si >= panes.length || si === idx) this.tabSoftExpand.set(tabId, -1) + this.repositionAll() + } + return } - - this.repositionAll() } // ─── Focus ───────────────────────────────────────────── @@ -249,6 +410,35 @@ 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 + } + const n = this.panes.length if (n <= 1) return const { cols } = this.calcGrid(n) @@ -267,6 +457,22 @@ export class DirectGridRenderer { } focusByClick(col: number, row: number): boolean { + if (this.isSoftExpanded) { + // Hit-test against actual pane positions in soft expand layout + for (let i = 0; i < this.panes.length; i++) { + const dp = this.panes[i]!.directPane + const bx = dp.screenX - 1 + const by = dp.screenY - 3 + const bw = dp.width + 2 + const bh = dp.height + 4 + if (col >= bx && col < bx + bw && row >= by && row < by + bh) { + this.setFocus(i) + return true + } + } + return false + } + const n = this.panes.length if (n === 0) return false const termW = process.stdout.columns || 120 @@ -285,6 +491,33 @@ export class DirectGridRenderer { return false } + // Determine which pane index was clicked (for soft expand) + getPaneIndexAtClick(col: number, row: number): number { + if (this.isSoftExpanded) { + for (let i = 0; i < this.panes.length; i++) { + const dp = this.panes[i]!.directPane + const bx = dp.screenX - 1 + const by = dp.screenY - 3 + const bw = dp.width + 2 + const bh = dp.height + 4 + if (col >= bx && col < bx + bw && row >= by && row < by + bh) return i + } + return -1 + } + const n = this.panes.length + if (n === 0) return -1 + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + 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 gc = Math.floor((col - 1) / cellW) + const gr = Math.floor((row - 2) / cellH) + const idx = gr * cols + gc + return (idx >= 0 && idx < n) ? idx : -1 + } + // ─── Chrome ──────────────────────────────────────────── drawChrome() { @@ -294,7 +527,10 @@ export class DirectGridRenderer { let out = SYNC_START - // Header (row 1) + // Tab bar (row 1) + out += this.drawTabBar(termW) + + // Header (row 2) const n = this.panes.length const fi = this._focusIndex + 1 let headerLeft: string, headerRight: string @@ -303,12 +539,15 @@ export class DirectGridRenderer { headerRight = `${DIM}drag to select │ cmd+c copy │ ${BOLD}Esc${RESET}${DIM} exit select${RESET}` } else if (this.isExpanded) { headerLeft = ` ${BOLD}cladm grid${RESET} — ${hexFg("#7dcfff")}${BOLD}EXPANDED${RESET} │ ${fi}/${n}` - headerRight = `${DIM}click ${BOLD}[SEL]${RESET}${DIM} select text │ click ${BOLD}[MIN]${RESET}${DIM} restore │ ctrl+\` picker${RESET}` + headerRight = `${DIM}click ${BOLD}[SEL]${RESET}${DIM} select text │ click ${BOLD}[MIN]${RESET}${DIM} restore │ ctrl+space picker${RESET}` + } else if (this.isSoftExpanded) { + headerLeft = ` ${BOLD}cladm grid${RESET} — ${hexFg("#bb9af7")}${BOLD}FOCUS${RESET} │ ${fi}/${n}` + headerRight = `${DIM}click pane to focus │ click ${BOLD}[MAX]${RESET}${DIM} fullscreen │ ctrl+e toggle${RESET}` } else { headerLeft = ` ${BOLD}cladm grid${RESET} — ${n} sessions │ focus: ${fi}/${n}` - headerRight = `${DIM}shift+arrows nav │ scroll/pgup/dn │ click ${BOLD}[MAX]${RESET}${DIM} expand │ ctrl+\` picker │ ctrl+w close${RESET}` + headerRight = `${DIM}shift+arrows nav │ click ${BOLD}[MAX]${RESET}${DIM} expand │ ctrl+space picker │ ctrl+w close${RESET}` } - out += `\x1b[1;1H\x1b[${termW}X${headerLeft} ${headerRight}` + out += `\x1b[2;1H\x1b[${termW}X${headerLeft} ${headerRight}` // Pane borders + titles if (this.isExpanded) { @@ -329,24 +568,72 @@ export class DirectGridRenderer { } else if (pane) { const color = getColor(pane.session.colorIndex) const sid = pane.session.sessionId ? ` ${DIM}#${pane.session.sessionId.slice(0, 8)}${RESET}` : "" - out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg(color)}▸${RESET} ${BOLD}${pane.session.projectName}${RESET}${sid} ${DIM}all input goes to focused pane${RESET}` + const expandNote = app.clickExpand ? `${DIM} │ click-expand: on${RESET}` : "" + out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg(color)}▸${RESET} ${BOLD}${pane.session.projectName}${RESET}${sid} ${DIM}all input goes to focused pane${RESET}${expandNote}` } else { - out += `\x1b[${termH};1H\x1b[${termW}X ${DIM}No sessions. Press ctrl+\` to return to picker.${RESET}` + out += `\x1b[${termH};1H\x1b[${termW}X ${DIM}No sessions. Press ctrl+space to return to picker.${RESET}` } out += SYNC_END this.writeRaw(out) } + private drawTabBar(termW: number): string { + this.tabBarHitRegions = [] + this.tabBarAddBtnCol = -1 + + let out = `\x1b[1;1H\x1b[${termW}X ` + let col = 2 + + // Picker tab (id = -1, meaning: switch to picker) + const pickerActive = app.viewMode === "picker" + const pickerLabel = pickerActive ? `${CYAN_FG}${BOLD}● Picker${RESET}` : `${DIM}○ Picker${RESET}` + out += pickerLabel + ` ${DIM}│${RESET} ` + this.tabBarHitRegions.push({ tabId: -1, startCol: col, endCol: col + 7 }) + col += 11 // "● Picker │ " + + // Grid tabs + for (const tab of app.gridTabs) { + const isActive = this._activeTabId === tab.id && app.viewMode === "grid" + const hasIdle = this.hasIdleInTab(tab.id) + const count = this.getTabPaneCount(tab.id) + const label = `${tab.name} (${count})` + + let tabText: string + if (isActive) { + tabText = `${CYAN_FG}${BOLD}● ${label}${RESET}` + } else if (hasIdle) { + tabText = `${YELLOW_FG}◉ ${label}${RESET}` + } else { + tabText = `${DIM}○ ${label}${RESET}` + } + + const startCol = col + out += tabText + ` ${DIM}│${RESET} ` + const visLen = 2 + label.length // "● " + label + this.tabBarHitRegions.push({ tabId: tab.id, startCol, endCol: startCol + visLen - 1 }) + col += visLen + 3 // + " │ " + } + + // [+] button + out += `${DIM}[+]${RESET}` + this.tabBarAddBtnCol = col + col += 3 + + return out + } + private drawPaneBorder(index: number): string { const pane = this.panes[index]! const dp = pane.directPane const isFocused = index === this._focusIndex const isFlashing = this.flashTimers.has(pane.session.name) + const isSoftExp = this._softExpandIndex === index const color = getColor(pane.session.colorIndex) let borderColor: string if (isFocused) borderColor = WHITE + else if (isSoftExp) borderColor = hexFg("#bb9af7") else if (isFlashing) borderColor = hexFg("#ff9e64") else borderColor = hexFg(color) @@ -368,16 +655,14 @@ export class DirectGridRenderer { let btnSection: string let btnVisibleLen: number if (this.isExpanded) { - // Expanded: [SEL] [MIN] at right const selColor = this._selectMode ? `${hexFg("#9ece6a")}${BOLD}` : `${DIM}` btnSection = `${RESET}${selColor}[SEL]${RESET}${borderColor}${hz}${RESET}${hexFg("#7dcfff")}[MIN]${RESET}${borderColor}` - btnVisibleLen = 5 + 1 + 5 // [SEL] + hz + [MIN] + btnVisibleLen = 5 + 1 + 5 } else { - // Grid: [MAX] at right btnSection = `${RESET}${DIM}[MAX]${RESET}${borderColor}` - btnVisibleLen = 5 // [MAX] + btnVisibleLen = 5 } - const hzFill = Math.max(0, bw - 2 - btnVisibleLen - 1) // -2 corners, -1 trailing hz + const hzFill = Math.max(0, bw - 2 - btnVisibleLen - 1) out += `\x1b[${by};${bx}H${borderColor}${tl}${hz.repeat(hzFill)}${btnSection}${hz}${tr}${RESET}` // Title row @@ -417,7 +702,6 @@ export class DirectGridRenderer { // ─── Content rendering ───────────────────────────────── private drawPane(index: number, lines: string[]) { - // In expanded mode, only draw the expanded pane if (this.isExpanded && index !== this._expandedIndex) return const pane = this.panes[index] if (!pane) return @@ -430,7 +714,6 @@ export class DirectGridRenderer { sendInputToFocused(rawSequence: string) { const pane = this.focusedPane if (!pane) return - // Reset scroll offset when user types (back to live view) const offset = getScrollOffset(pane.session.name) if (offset > 0) { scrollPane(pane.session.name, "down", offset) @@ -442,15 +725,14 @@ export class DirectGridRenderer { sendScrollToFocused(direction: "up" | "down", lines = 5) { const pane = this.focusedPane if (!pane) return - const offset = scrollPane(pane.session.name, direction, lines) - // Update title to show scroll indicator + scrollPane(pane.session.name, direction, lines) this.drawChrome() } // ─── Status ──────────────────────────────────────────── markIdle(sessionName: string) { - const pane = this.panes.find(p => p.session.name === sessionName) + const pane = this.findPaneAcrossTabs(sessionName) if (!pane) return if (pane.status !== "idle") { pane.status = "idle"; pane.statusSince = Date.now() } this.startFlash(sessionName) @@ -458,7 +740,7 @@ export class DirectGridRenderer { } markBusy(sessionName: string) { - const pane = this.panes.find(p => p.session.name === sessionName) + const pane = this.findPaneAcrossTabs(sessionName) if (!pane) return if (pane.status !== "busy") { pane.status = "busy"; pane.statusSince = Date.now() } this.clearFlash(sessionName) @@ -466,13 +748,21 @@ export class DirectGridRenderer { } clearMark(sessionName: string) { - const pane = this.panes.find(p => p.session.name === sessionName) + const pane = this.findPaneAcrossTabs(sessionName) if (!pane) return pane.status = null; pane.statusSince = 0 this.clearFlash(sessionName) this.drawChrome() } + private findPaneAcrossTabs(sessionName: string): GridPaneInfo | null { + for (const [, panes] of this.tabPanes) { + const p = panes.find(p => p.session.name === sessionName) + if (p) return p + } + return null + } + startFlash(sessionName: string) { if (this.flashTimers.has(sessionName)) return const timer = setInterval(() => this.drawChrome(), 400) @@ -496,9 +786,10 @@ 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 { cols, rows } = this.calcGrid(n) const cellW = Math.floor(termW / cols) - const cellH = Math.floor((termH - 2) / rows) + const cellH = Math.floor((termH - chromeTop - 1) / rows) // -1 for footer const regions: { screenX: number, screenY: number, contentW: number, contentH: number }[] = [] for (let i = 0; i < n; i++) { @@ -507,7 +798,7 @@ export class DirectGridRenderer { const contentW = cellW - 2 const contentH = cellH - 4 const screenX = gc * cellW + 2 - const screenY = 2 + gr * cellH + 3 + const screenY = chromeTop + gr * cellH + 3 regions.push({ screenX, screenY, @@ -519,18 +810,55 @@ export class DirectGridRenderer { } repositionAll() { + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const chromeTop = 3 + if (this.isExpanded) { - // Expanded: give the expanded pane full screen area - const termW = process.stdout.columns || 120 - const termH = process.stdout.rows || 40 + // Fullscreen: expanded pane gets all space const contentW = termW - 2 - const contentH = termH - 2 - 4 // -2 header/footer, -4 border chrome + const contentH = termH - chromeTop - 1 - 4 // -1 footer, -4 border chrome const pane = this.panes[this._expandedIndex]! - pane.directPane.reposition(2, 5, Math.max(contentW, 10), Math.max(contentH, 2)) + pane.directPane.reposition(2, chromeTop + 3, Math.max(contentW, 10), Math.max(contentH, 2)) resizeSession(pane.session.name, Math.max(contentW, 10), Math.max(contentH, 2)) 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 + const sei = this._softExpandIndex + const n = this.panes.length + const availH = termH - chromeTop - 1 // available rows for content + const availW = termW + + // 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}`) + + // 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) + + 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}`) + } + } } else { + // Equal grid const regions = this.calcPaneRegions() for (let i = 0; i < this.panes.length; i++) { const pane = this.panes[i]! @@ -557,7 +885,10 @@ export class DirectGridRenderer { destroyAll() { this.stop() - this.panes = [] - this._focusIndex = 0 + this.tabPanes.clear() + this.tabFocus.clear() + this.tabExpanded.clear() + this.tabSoftExpand.clear() + this._activeTabId = -1 } } diff --git a/src/grid/view-switch.ts b/src/grid/view-switch.ts index 85dd155..291dee9 100644 --- a/src/grid/view-switch.ts +++ b/src/grid/view-switch.ts @@ -22,13 +22,44 @@ export function switchToGrid() { app.rawStdoutWrite("\x1b[?1000h") app.rawStdoutWrite("\x1b[?1006h") - if (isNew || app.directGrid.paneCount === 0) { - app.directGrid.start() + if (isNew || app.directGrid!.totalPaneCount === 0) { + app.directGrid!.start() } else { - app.directGrid.resume() + app.directGrid!.resume() } } +export function switchToGridTab(tabId: number) { + const tab = app.gridTabs.find(t => t.id === tabId) + if (!tab) return + + // Track last grid tab for Ctrl+Space toggle + app.lastGridTabIndex = app.gridTabs.indexOf(tab) + + if (app.viewMode !== "grid") { + switchToGrid() + } + + app.activeTabIndex = app.gridTabs.indexOf(tab) + 1 + app.directGrid!.setActiveTab(tabId) +} + +export function createNewGridTab(): number { + const tabId = app.nextTabId++ + const tab = { id: tabId, name: `Tab ${tabId}` } + app.gridTabs.push(tab) + + if (!app.directGrid) { + app.directGrid = new DirectGridRenderer(app.rawStdoutWrite) + } + app.directGrid.addTab(tab) + + // Switch to the new tab + switchToGridTab(tabId) + + return tabId +} + export function resizeGridPanes() { if (!app.directGrid || app.directGrid.paneCount === 0) return app.directGrid.repositionAll() diff --git a/src/index.ts b/src/index.ts index b018a37..09bd231 100755 --- a/src/index.ts +++ b/src/index.ts @@ -81,6 +81,12 @@ async function main() { height: "100%", }) + app.tabBarText = new TextRenderable(app.renderer, { + width: "100%", + height: 1, + flexShrink: 0, + }) + app.headerText = new TextRenderable(app.renderer, { width: "100%", height: 1, @@ -148,6 +154,7 @@ async function main() { flexShrink: 0, }) + app.mainBox.add(app.tabBarText) 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 486a110..ddcb933 100644 --- a/src/input/handlers.ts +++ b/src/input/handlers.ts @@ -1,8 +1,8 @@ import type { KeyEvent } from "@opentui/core" import { app } from "../lib/state" -import { updateAll, rebuildDisplayRows, applySortMode } from "../ui/panels" +import { updateAll, rebuildDisplayRows, applySortMode, updateTabBar } from "../ui/panels" import { extractKeyboardInput, extractMouseEvents } from "./parser" -import { switchToGrid } from "../grid/view-switch" +import { switchToGrid, switchToGridTab, createNewGridTab } from "../grid/view-switch" import { doLaunch } from "../actions/launch" import { launchSelections } from "../actions/launcher" import { loadSessions } from "../data/sessions" @@ -136,6 +136,49 @@ export function hitTestListRow(screenRow: number): number { return -1 } +// ─── Tab switching helpers ─────────────────────────────────────────── + +function handleTabSwitch(tabNumber: number) { + if (tabNumber === 0) { + // Switch to picker + if (app.viewMode === "grid") switchToPicker() + return + } + // tabNumber 1-9 → grid tab index 0-8 + const tabIndex = tabNumber - 1 + if (tabIndex < app.gridTabs.length) { + switchToGridTab(app.gridTabs[tabIndex].id) + } +} + +function handleNextTab() { + if (app.gridTabs.length === 0) return + if (app.viewMode === "picker") { + switchToGridTab(app.gridTabs[0].id) + return + } + const currentIdx = app.gridTabs.findIndex(t => t.id === app.directGrid?.activeTabId) + if (currentIdx < app.gridTabs.length - 1) { + switchToGridTab(app.gridTabs[currentIdx + 1].id) + } else { + switchToPicker() // wrap around to picker + } +} + +function handlePrevTab() { + if (app.gridTabs.length === 0) return + if (app.viewMode === "picker") { + switchToGridTab(app.gridTabs[app.gridTabs.length - 1].id) + return + } + const currentIdx = app.gridTabs.findIndex(t => t.id === app.directGrid?.activeTabId) + if (currentIdx > 0) { + switchToGridTab(app.gridTabs[currentIdx - 1].id) + } else { + switchToPicker() // wrap to picker + } +} + // ─── Picker click ──────────────────────────────────────────────────── export function handlePickerClick(_col: number, screenRow: number) { @@ -146,6 +189,39 @@ export function handlePickerClick(_col: number, screenRow: number) { updateAll() } +// ─── Picker tab bar click ──────────────────────────────────────────── + +function handlePickerTabBarClick(col: number, screenRow: number) { + // Tab bar is at row 1 in picker (rendered as OpenTUI text) + if (screenRow !== 1) return false + // Hit test against tab bar positions (approximate, since OpenTUI renders it) + // We compute positions similar to the grid tab bar + let c = 2 + // Picker tab + const pickerEnd = c + 7 + if (col >= c && col <= pickerEnd) return false // already on picker + c += 11 + + for (const tab of app.gridTabs) { + const count = app.directGrid?.getTabPaneCount(tab.id) ?? 0 + const label = `${tab.name} (${count})` + const visLen = 2 + label.length + if (col >= c && col < c + visLen) { + switchToGridTab(tab.id) + return true + } + c += visLen + 3 + } + + // [+] button + if (col >= c && col <= c + 2) { + createNewGridTab() + return true + } + + return false +} + // ─── Keyboard ──────────────────────────────────────────────────────── export async function handleKeypress(key: KeyEvent) { @@ -284,10 +360,6 @@ export async function handleKeypress(key: KeyEvent) { app.renderer.destroy() return - case "t": - if (app.directGrid && app.directGrid.paneCount > 0) switchToGrid() - return - default: return } @@ -301,37 +373,82 @@ export async function handleKeypress(key: KeyEvent) { export async function handleGridInput(rawSequence: string): Promise { if (app.viewMode !== "grid" || !app.directGrid) return false - if (rawSequence === "\x1b" && app.directGrid.isExpanded) { - app.directGrid.collapsePane() + // Esc: collapse expanded/soft-expanded, or do nothing + if (rawSequence === "\x1b") { + if (app.directGrid.isExpanded) { app.directGrid.collapsePane(); return true } + if (app.directGrid.isSoftExpanded) { app.directGrid.softCollapsePane(); return true } return true } - if (rawSequence === "\x1e" || rawSequence === "\x1b`" || rawSequence === "\x00") { + // Ctrl+Space → switch to picker + if (rawSequence === "\x00") { + app.lastGridTabIndex = app.gridTabs.findIndex(t => t.id === app.directGrid!.activeTabId) switchToPicker() return true } + // Ctrl+T → new tab + if (rawSequence === "\x14") { + createNewGridTab() + return true + } + + // Ctrl+E → toggle click-to-expand + if (rawSequence === "\x05") { + app.clickExpand = !app.clickExpand + if (!app.clickExpand && app.directGrid.isSoftExpanded) app.directGrid.softCollapsePane() + app.directGrid.drawChrome() + return true + } + + // Alt+1 through Alt+9 → switch tab + if (rawSequence.length === 2 && rawSequence[0] === "\x1b" && rawSequence[1] >= "1" && rawSequence[1] <= "9") { + handleTabSwitch(parseInt(rawSequence[1])) + return true + } + + // Alt+n → next tab, Alt+p → prev tab + if (rawSequence === "\x1bn") { handleNextTab(); return true } + if (rawSequence === "\x1bp") { handlePrevTab(); return true } + + // Ctrl+N / Ctrl+P → focus next/prev pane if (rawSequence === "\x0e") { app.directGrid.focusNext(); return true } if (rawSequence === "\x10") { app.directGrid.focusPrev(); return true } + // Ctrl+F → open folder if (rawSequence === "\x06") { const pane = app.directGrid.focusedPane if (pane) Bun.spawn(["open", pane.session.projectPath]) return true } + // Ctrl+W → close pane (remove tab if last pane) if (rawSequence === "\x17") { const pane = app.directGrid.focusedPane if (pane) { if (app.directGrid.isExpanded) app.directGrid.collapsePane() + if (app.directGrid.isSoftExpanded) app.directGrid.softCollapsePane() const { killSession } = await import("../pty/session-manager") app.directGrid.removePane(pane.session.name) await killSession(pane.session.name) - if (app.directGrid.paneCount === 0) switchToPicker() + if (app.directGrid.paneCount === 0) { + // Remove current tab and switch to previous or picker + const currentTabId = app.directGrid.activeTabId + const tabIdx = app.gridTabs.findIndex(t => t.id === currentTabId) + app.directGrid.removeTab(currentTabId) + app.gridTabs.splice(tabIdx, 1) + if (app.gridTabs.length > 0) { + const prevIdx = Math.max(0, tabIdx - 1) + switchToGridTab(app.gridTabs[prevIdx].id) + } else { + switchToPicker() + } + } } return true } + // Page Up/Down → scroll if (rawSequence === "\x1b[5~") { app.directGrid.sendScrollToFocused("up"); return true } if (rawSequence === "\x1b[6~") { app.directGrid.sendScrollToFocused("down"); return true } @@ -343,9 +460,10 @@ export async function handleGridInput(rawSequence: string): Promise { export function switchToPicker() { app.viewMode = "picker" + app.activeTabIndex = 0 if (app.directGrid) { if (app.directGrid.selectMode) app.directGrid.exitSelectMode() - if (app.directGrid.paneCount > 0) app.directGrid.pause() + if (app.directGrid.totalPaneCount > 0) app.directGrid.pause() } app.renderer.resume() process.stdin.removeAllListeners("data") @@ -376,7 +494,27 @@ function processGridInput(str: string) { if (btn?.action === "max") dg.expandPane(btn.paneIndex) else if (btn?.action === "min") dg.collapsePane() else if (btn?.action === "sel") dg.enterSelectMode() - else dg.focusByClick(me.col, me.row) + else if (btn?.action === "tab") { + if (btn.tabId === -1) { + // Switch to picker + app.lastGridTabIndex = app.gridTabs.findIndex(t => t.id === dg.activeTabId) + switchToPicker() + } else if (btn.tabId !== undefined) { + switchToGridTab(btn.tabId) + } + } + else if (btn?.action === "newtab") createNewGridTab() + else { + // Pane body click + if (app.clickExpand && !dg.isExpanded) { + const clickedIdx = dg.getPaneIndexAtClick(me.col, me.row) + if (clickedIdx >= 0) { + dg.toggleSoftExpand(clickedIdx) + } + } else { + dg.focusByClick(me.col, me.row) + } + } continue } } @@ -397,15 +535,24 @@ function processGridInput(str: string) { // ─── Stdin: picker mode ────────────────────────────────────────────── function processPickerInput(str: string) { - // Ctrl+Space → toggle to grid - if (str.includes("\x00") && app.directGrid && app.directGrid.paneCount > 0) { - switchToGrid() - return + // Ctrl+Space → toggle to last grid tab + if (str.includes("\x00")) { + if (app.directGrid && app.directGrid.totalPaneCount > 0) { + // Switch to last active grid tab + if (app.gridTabs.length > 0) { + const idx = Math.min(app.lastGridTabIndex, app.gridTabs.length - 1) + switchToGridTab(app.gridTabs[Math.max(0, idx)].id) + } + return + } } const pickerMouse = extractMouseEvents(str) for (const me of pickerMouse) { - if (me.btn === 0 && !me.release) handlePickerClick(me.col, me.row) + if (me.btn === 0 && !me.release) { + if (handlePickerTabBarClick(me.col, me.row)) continue + handlePickerClick(me.col, me.row) + } if (me.btn === 64) { if (app.cursor > 0) { app.cursor--; updateAll() } } if (me.btn === 65) { if (app.cursor < app.displayRows.length - 1) { app.cursor++; updateAll() } } } @@ -413,8 +560,21 @@ function processPickerInput(str: string) { const keyboard = extractKeyboardInput(str) if (!keyboard) return + // Check for Alt+digit and Alt+n/p before normal key processing let ki = 0 while (ki < keyboard.length) { + // Alt sequences + if (keyboard[ki] === "\x1b" && ki + 1 < keyboard.length) { + const next = keyboard[ki + 1] + if (next >= "1" && next <= "9") { + handleTabSwitch(parseInt(next)) + ki += 2 + continue + } + if (next === "n") { handleNextTab(); ki += 2; continue } + if (next === "p") { handlePrevTab(); ki += 2; continue } + } + let matched = false for (let len = Math.min(8, keyboard.length - ki); len >= 1; len--) { const mapped = KEY_MAP[keyboard.slice(ki, ki + len)] diff --git a/src/input/parser.ts b/src/input/parser.ts index c1b0b2a..ad62e74 100644 --- a/src/input/parser.ts +++ b/src/input/parser.ts @@ -65,9 +65,9 @@ export function extractKeyboardInput(data: string): string { i += 3; continue } - // \x1b` (ctrl+backtick) — keep as keyboard shortcut - if (next === "`") { - keyboard += "\x1b`" + // Alt+digit (1-9) and Alt+letter (n, p) — keep as keyboard shortcuts + if ((next >= "1" && next <= "9") || next === "n" || next === "p") { + keyboard += data.slice(i, i + 2) i += 2; continue } diff --git a/src/lib/state.ts b/src/lib/state.ts index 66867b9..be22839 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -7,6 +7,11 @@ import type { IdleSessionInfo } from "../data/monitor" export type ViewMode = "picker" | "grid" +export interface GridTab { + id: number + name: string +} + export const app = { // Config demoMode: Bun.argv.includes("--demo"), @@ -36,9 +41,17 @@ export const app = { mainBox: null as BoxRenderable | null, rawStdoutWrite: null as unknown as (s: string) => boolean, + // Tabs + activeTabIndex: 0, // 0 = picker, 1+ = grid tab index+1 + gridTabs: [] as GridTab[], // grid tabs only (not picker) + nextTabId: 1, // auto-increment for tab ids + clickExpand: true, // click-to-expand feature toggle + lastGridTabIndex: 0, // last active grid tab for Ctrl+Space toggle + // UI refs (set during init) renderer: null as unknown as CliRenderer, headerText: null as unknown as TextRenderable, + tabBarText: 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 c44810e..b270f95 100644 --- a/src/ui/panels.ts +++ b/src/ui/panels.ts @@ -67,6 +67,38 @@ export function applySortMode() { rebuildDisplayRows() } +// ─── Tab bar ───────────────────────────────────────────────────────── + +export function updateTabBar() { + if (!app.tabBarText) return + + // Build tab bar segments using styled text + const sep = dim(" │ ") + const pickerActive = app.viewMode === "picker" + const pickerTab = pickerActive ? t`${cyan("●")} ${bold("Picker")}` : t`${dim("○ Picker")}` + + // Start with picker + let content = t` ${pickerTab}` + + // Grid tabs + for (const tab of app.gridTabs) { + const count = app.directGrid?.getTabPaneCount(tab.id) ?? 0 + const hasIdle = app.directGrid?.hasIdleInTab(tab.id) ?? false + const isActive = app.viewMode === "grid" && app.directGrid?.activeTabId === tab.id + const label = `${tab.name} (${count})` + if (isActive) { + content = t`${content}${sep}${cyan("●")} ${bold(label)}` + } else if (hasIdle) { + content = t`${content}${sep}${yellow("◉")} ${label}` + } else { + content = t`${content}${sep}${dim("○ " + label)}` + } + } + + content = t`${content}${sep}${dim("[+]")}` + app.tabBarText.content = content +} + // ─── Header / Footer ───────────────────────────────────────────────── export function updateHeader() { @@ -93,7 +125,7 @@ export function updateColumnHeaders() { } export function updateFooter() { - const gridHint = app.directGrid && app.directGrid.paneCount > 0 ? " │ ^space grid" : "" + const gridHint = app.directGrid && app.directGrid.totalPaneCount > 0 ? " │ ^space grid" : "" if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0) { app.footerText.content = t` ${dim( "↑↓ nav │ tab/shift-tab idle select │ enter focus │ i preview │ space select │ a all │ n none │ s sort │ q quit" + gridHint @@ -327,6 +359,7 @@ function clearChildren(box: { getChildren(): { id: string }[]; remove(id: string export function updateAll() { if (app.destroyed) return + updateTabBar() updateHeader() rebuildList() updateBottomPanel()