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 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-28 10:45:25 +00:00
parent cad6df1a9b
commit 9e424c192a
8 changed files with 692 additions and 109 deletions

View File

@@ -1,6 +1,6 @@
import { app } from "../lib/state" import { app } from "../lib/state"
import { updateAll, rebuildDisplayRows } from "../ui/panels" 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 { loadSessions } from "../data/sessions"
import { createSession } from "../pty/session-manager" import { createSession } from "../pty/session-manager"
@@ -43,11 +43,19 @@ export async function doLaunch() {
if (items.length === 0) return 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() ensureGridView()
const termW = process.stdout.columns || 120 const termW = process.stdout.columns || 120
const termH = process.stdout.rows || 40 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 cols = totalPanes <= 1 ? 1 : totalPanes <= 2 ? 2 : totalPanes <= 4 ? 2 : 3
const rows = Math.ceil(totalPanes / cols) const rows = Math.ceil(totalPanes / cols)
const paneW = Math.max(Math.floor(termW / cols) - 2, 20) const paneW = Math.max(Math.floor(termW / cols) - 2, 20)
@@ -62,7 +70,7 @@ export async function doLaunch() {
width: paneW, width: paneW,
height: paneH, height: paneH,
}) })
await app.directGrid!.addPane(session) await app.directGrid!.addPane(session, targetTabId)
} }
app.selectedProjects.clear() app.selectedProjects.clear()

View File

@@ -5,6 +5,7 @@
import { DirectPane } from "./direct-pane" import { DirectPane } from "./direct-pane"
import { startCapture, stopCapture, resizeCapture, resetHash, getLatestFrame, scrollPane, getScrollOffset } from "../pty/capture" import { startCapture, stopCapture, resizeCapture, resetHash, getLatestFrame, scrollPane, getScrollOffset } from "../pty/capture"
import { writeToSession, resizeSession, killSession, type PtySession } from "../pty/session-manager" import { writeToSession, resizeSession, killSession, type PtySession } from "../pty/session-manager"
import { app, type GridTab } from "../lib/state"
export type PaneStatus = "busy" | "idle" | null export type PaneStatus = "busy" | "idle" | null
@@ -13,6 +14,7 @@ export interface GridPaneInfo {
directPane: DirectPane directPane: DirectPane
status: PaneStatus status: PaneStatus
statusSince: number statusSince: number
tabId: number
} }
const PROJECT_COLORS = [ const PROJECT_COLORS = [
@@ -31,6 +33,13 @@ function hexFg(hex: string): string {
return `\x1b[38;2;${r};${g};${b}m` 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 RESET = "\x1b[0m"
const BOLD = "\x1b[1m" const BOLD = "\x1b[1m"
const DIM = "\x1b[2m" const DIM = "\x1b[2m"
@@ -41,6 +50,11 @@ const SYNC_START = "\x1b[?2026h"
const SYNC_END = "\x1b[?2026l" const SYNC_END = "\x1b[?2026l"
const CLEAR = "\x1b[2J\x1b[H" 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 { function fmtElapsed(sinceMs: number): string {
if (!sinceMs) return "" if (!sinceMs) return ""
const sec = Math.floor((Date.now() - sinceMs) / 1000) const sec = Math.floor((Date.now() - sinceMs) / 1000)
@@ -55,19 +69,54 @@ function fmtElapsed(sinceMs: number): string {
} }
export class DirectGridRenderer { export class DirectGridRenderer {
private panes: GridPaneInfo[] = [] // Per-tab state
private _focusIndex = 0 private tabPanes = new Map<number, GridPaneInfo[]>()
private tabFocus = new Map<number, number>()
private tabExpanded = new Map<number, number>() // fullscreen expand
private tabSoftExpand = new Map<number, number>() // soft expand (70/30)
private _activeTabId = -1
private writeRaw: (s: string) => boolean private writeRaw: (s: string) => boolean
private flashTimers = new Map<string, ReturnType<typeof setInterval>>() private flashTimers = new Map<string, ReturnType<typeof setInterval>>()
private titleTimer: ReturnType<typeof setInterval> | null = null private titleTimer: ReturnType<typeof setInterval> | null = null
private running = false private running = false
private _selectMode = 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) { constructor(rawWrite: (s: string) => boolean) {
this.writeRaw = rawWrite 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 ───────────────────────────────────────── // ─── Lifecycle ─────────────────────────────────────────
start() { start() {
@@ -82,30 +131,32 @@ export class DirectGridRenderer {
if (this.titleTimer) { clearInterval(this.titleTimer); this.titleTimer = null } if (this.titleTimer) { clearInterval(this.titleTimer); this.titleTimer = null }
for (const timer of this.flashTimers.values()) clearInterval(timer) for (const timer of this.flashTimers.values()) clearInterval(timer)
this.flashTimers.clear() this.flashTimers.clear()
for (const p of this.panes) { for (const [, panes] of this.tabPanes) {
for (const p of panes) {
p.directPane.detach() p.directPane.detach()
stopCapture(p.session.name) stopCapture(p.session.name)
} }
}
this.writeRaw(SHOW_CURSOR) this.writeRaw(SHOW_CURSOR)
} }
pause() { pause() {
this.running = false this.running = false
if (this.titleTimer) { clearInterval(this.titleTimer); this.titleTimer = null } if (this.titleTimer) { clearInterval(this.titleTimer); this.titleTimer = null }
// Detach frame listeners (stops rendering) but keep captures alive for (const [, panes] of this.tabPanes) {
for (const p of this.panes) p.directPane.detach() for (const p of panes) p.directPane.detach()
}
} }
resume() { resume() {
this.running = true this.running = true
this.writeRaw(HIDE_CURSOR + CLEAR) this.writeRaw(HIDE_CURSOR + CLEAR)
// Reattach frame listeners and redraw const panes = this.panes
for (let i = 0; i < this.panes.length; i++) { for (let i = 0; i < panes.length; i++) {
const p = this.panes[i] const p = panes[i]!
const dp = p.directPane
const idx = i const idx = i
dp.attach(p.session.name) p.directPane.attach(p.session.name)
dp.onFrame = (lines) => { p.directPane.onFrame = (lines) => {
if (!this.running) return if (!this.running) return
this.drawPane(idx, lines) this.drawPane(idx, lines)
} }
@@ -118,21 +169,24 @@ export class DirectGridRenderer {
get focusIndex() { return this._focusIndex } get focusIndex() { return this._focusIndex }
get paneCount() { return this.panes.length } 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 focusedPane(): GridPaneInfo | null { return this.panes[this._focusIndex] ?? null }
get selectMode() { return this._selectMode } get selectMode() { return this._selectMode }
get isExpanded() { return this._expandedIndex >= 0 } get isExpanded() { return this._expandedIndex >= 0 }
get isSoftExpanded() { return this._softExpandIndex >= 0 }
get activeTabId() { return this._activeTabId }
enterSelectMode() { enterSelectMode() {
if (!this.isExpanded) return // Only allow in expanded mode if (!this.isExpanded) return
this._selectMode = true this._selectMode = true
this.writeRaw("\x1b[?1000l\x1b[?1006l") // Disable mouse reporting this.writeRaw("\x1b[?1000l\x1b[?1006l")
this.writeRaw(SHOW_CURSOR) this.writeRaw(SHOW_CURSOR)
this.drawChrome() this.drawChrome()
} }
exitSelectMode() { exitSelectMode() {
this._selectMode = false this._selectMode = false
this.writeRaw("\x1b[?1000h\x1b[?1006h") // Re-enable mouse reporting this.writeRaw("\x1b[?1000h\x1b[?1006h")
this.writeRaw(HIDE_CURSOR) this.writeRaw(HIDE_CURSOR)
this.drawChrome() this.drawChrome()
} }
@@ -141,6 +195,7 @@ export class DirectGridRenderer {
const idx = index ?? this._focusIndex const idx = index ?? this._focusIndex
if (idx < 0 || idx >= this.panes.length) return if (idx < 0 || idx >= this.panes.length) return
this._expandedIndex = idx this._expandedIndex = idx
this._softExpandIndex = -1
this._focusIndex = idx this._focusIndex = idx
this.repositionAll() this.repositionAll()
} }
@@ -148,11 +203,103 @@ export class DirectGridRenderer {
collapsePane() { collapsePane() {
if (this._selectMode) this.exitSelectMode() if (this._selectMode) this.exitSelectMode()
this._expandedIndex = -1 this._expandedIndex = -1
this._softExpandIndex = -1
this.repositionAll() 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. // 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) const indicesToCheck = this.isExpanded ? [this._expandedIndex] : this.panes.map((_, i) => i)
for (const i of indicesToCheck) { for (const i of indicesToCheck) {
const dp = this.panes[i]!.directPane const dp = this.panes[i]!.directPane
@@ -164,8 +311,6 @@ export class DirectGridRenderer {
if (row !== btnRow) continue if (row !== btnRow) continue
if (this.isExpanded) { 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 minRight = bx + bw - 2
const minLeft = minRight - 4 const minLeft = minRight - 4
if (col >= minLeft && col <= minRight) return { action: "min", paneIndex: i } if (col >= minLeft && col <= minRight) return { action: "min", paneIndex: i }
@@ -173,7 +318,6 @@ export class DirectGridRenderer {
const selLeft = selRight - 4 const selLeft = selRight - 4
if (col >= selLeft && col <= selRight) return { action: "sel", paneIndex: i } if (col >= selLeft && col <= selRight) return { action: "sel", paneIndex: i }
} else { } else {
// Grid: button is [MAX] at top-right
const btnLeft = bx + bw - 7 const btnLeft = bx + bw - 7
const btnRight = bx + bw - 3 const btnRight = bx + bw - 3
if (col >= btnLeft && col <= btnRight) return { action: "max", paneIndex: i } if (col >= btnLeft && col <= btnRight) return { action: "max", paneIndex: i }
@@ -184,51 +328,68 @@ export class DirectGridRenderer {
// ─── Pane management ─────────────────────────────────── // ─── Pane management ───────────────────────────────────
async addPane(session: PtySession): Promise<GridPaneInfo> { async addPane(session: PtySession, tabId?: number): Promise<GridPaneInfo> {
const regions = this.calcPaneRegions(this.panes.length + 1) const tid = tabId ?? this._activeTabId
const idx = this.panes.length let panes = this.tabPanes.get(tid)
const region = regions[idx]! 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)!
}
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 dp = new DirectPane(region.screenX, region.screenY, region.contentW, region.contentH)
const info: GridPaneInfo = { session, directPane: dp, status: null, statusSince: 0 } const info: GridPaneInfo = { session, directPane: dp, status: null, statusSince: 0, tabId: tid }
this.panes.push(info) panes.push(info)
// Resize PTY to match content area
resizeSession(session.name, region.contentW, region.contentH) resizeSession(session.name, region.contentW, region.contentH)
// Start capture (reads PTY stdout and pushes frames)
startCapture(session) startCapture(session)
// Subscribe to push frames (must set callback AFTER attach, since attach calls detach which nulls onFrame) if (isActive) {
dp.attach(session.name) dp.attach(session.name)
dp.onFrame = (lines) => { dp.onFrame = (lines) => {
if (!this.running) return if (!this.running) return
this.drawPane(idx, lines) this.drawPane(idx, lines)
} }
// Reposition all existing panes
this.repositionAll() this.repositionAll()
}
return info return info
} }
removePane(sessionName: string) { removePane(sessionName: string) {
const idx = this.panes.findIndex(p => p.session.name === sessionName) // Search across all tabs
if (idx < 0) return 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]! const pane = panes[idx]!
pane.directPane.detach() pane.directPane.detach()
stopCapture(pane.session.name) stopCapture(pane.session.name)
killSession(pane.session.name) killSession(pane.session.name)
this.clearFlash(sessionName) this.clearFlash(sessionName)
this.panes.splice(idx, 1) 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() this.repositionAll()
} }
return
}
}
// ─── Focus ───────────────────────────────────────────── // ─── Focus ─────────────────────────────────────────────
@@ -249,6 +410,35 @@ export class DirectGridRenderer {
} }
focusByDirection(dir: "up" | "down" | "left" | "right") { 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 const n = this.panes.length
if (n <= 1) return if (n <= 1) return
const { cols } = this.calcGrid(n) const { cols } = this.calcGrid(n)
@@ -267,6 +457,22 @@ export class DirectGridRenderer {
} }
focusByClick(col: number, row: number): boolean { 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 const n = this.panes.length
if (n === 0) return false if (n === 0) return false
const termW = process.stdout.columns || 120 const termW = process.stdout.columns || 120
@@ -285,6 +491,33 @@ export class DirectGridRenderer {
return false 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 ──────────────────────────────────────────── // ─── Chrome ────────────────────────────────────────────
drawChrome() { drawChrome() {
@@ -294,7 +527,10 @@ export class DirectGridRenderer {
let out = SYNC_START let out = SYNC_START
// Header (row 1) // Tab bar (row 1)
out += this.drawTabBar(termW)
// Header (row 2)
const n = this.panes.length const n = this.panes.length
const fi = this._focusIndex + 1 const fi = this._focusIndex + 1
let headerLeft: string, headerRight: string 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}` headerRight = `${DIM}drag to select │ cmd+c copy │ ${BOLD}Esc${RESET}${DIM} exit select${RESET}`
} else if (this.isExpanded) { } else if (this.isExpanded) {
headerLeft = ` ${BOLD}cladm grid${RESET}${hexFg("#7dcfff")}${BOLD}EXPANDED${RESET}${fi}/${n}` 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 { } else {
headerLeft = ` ${BOLD}cladm grid${RESET}${n} sessions │ focus: ${fi}/${n}` 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 // Pane borders + titles
if (this.isExpanded) { if (this.isExpanded) {
@@ -329,24 +568,72 @@ export class DirectGridRenderer {
} else if (pane) { } else if (pane) {
const color = getColor(pane.session.colorIndex) const color = getColor(pane.session.colorIndex)
const sid = pane.session.sessionId ? ` ${DIM}#${pane.session.sessionId.slice(0, 8)}${RESET}` : "" 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 { } 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 out += SYNC_END
this.writeRaw(out) 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 { private drawPaneBorder(index: number): string {
const pane = this.panes[index]! const pane = this.panes[index]!
const dp = pane.directPane const dp = pane.directPane
const isFocused = index === this._focusIndex const isFocused = index === this._focusIndex
const isFlashing = this.flashTimers.has(pane.session.name) const isFlashing = this.flashTimers.has(pane.session.name)
const isSoftExp = this._softExpandIndex === index
const color = getColor(pane.session.colorIndex) const color = getColor(pane.session.colorIndex)
let borderColor: string let borderColor: string
if (isFocused) borderColor = WHITE if (isFocused) borderColor = WHITE
else if (isSoftExp) borderColor = hexFg("#bb9af7")
else if (isFlashing) borderColor = hexFg("#ff9e64") else if (isFlashing) borderColor = hexFg("#ff9e64")
else borderColor = hexFg(color) else borderColor = hexFg(color)
@@ -368,16 +655,14 @@ export class DirectGridRenderer {
let btnSection: string let btnSection: string
let btnVisibleLen: number let btnVisibleLen: number
if (this.isExpanded) { if (this.isExpanded) {
// Expanded: [SEL] [MIN] at right
const selColor = this._selectMode ? `${hexFg("#9ece6a")}${BOLD}` : `${DIM}` const selColor = this._selectMode ? `${hexFg("#9ece6a")}${BOLD}` : `${DIM}`
btnSection = `${RESET}${selColor}[SEL]${RESET}${borderColor}${hz}${RESET}${hexFg("#7dcfff")}[MIN]${RESET}${borderColor}` 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 { } else {
// Grid: [MAX] at right
btnSection = `${RESET}${DIM}[MAX]${RESET}${borderColor}` 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}` out += `\x1b[${by};${bx}H${borderColor}${tl}${hz.repeat(hzFill)}${btnSection}${hz}${tr}${RESET}`
// Title row // Title row
@@ -417,7 +702,6 @@ export class DirectGridRenderer {
// ─── Content rendering ───────────────────────────────── // ─── Content rendering ─────────────────────────────────
private drawPane(index: number, lines: string[]) { private drawPane(index: number, lines: string[]) {
// In expanded mode, only draw the expanded pane
if (this.isExpanded && index !== this._expandedIndex) return if (this.isExpanded && index !== this._expandedIndex) return
const pane = this.panes[index] const pane = this.panes[index]
if (!pane) return if (!pane) return
@@ -430,7 +714,6 @@ export class DirectGridRenderer {
sendInputToFocused(rawSequence: string) { sendInputToFocused(rawSequence: string) {
const pane = this.focusedPane const pane = this.focusedPane
if (!pane) return if (!pane) return
// Reset scroll offset when user types (back to live view)
const offset = getScrollOffset(pane.session.name) const offset = getScrollOffset(pane.session.name)
if (offset > 0) { if (offset > 0) {
scrollPane(pane.session.name, "down", offset) scrollPane(pane.session.name, "down", offset)
@@ -442,15 +725,14 @@ export class DirectGridRenderer {
sendScrollToFocused(direction: "up" | "down", lines = 5) { sendScrollToFocused(direction: "up" | "down", lines = 5) {
const pane = this.focusedPane const pane = this.focusedPane
if (!pane) return if (!pane) return
const offset = scrollPane(pane.session.name, direction, lines) scrollPane(pane.session.name, direction, lines)
// Update title to show scroll indicator
this.drawChrome() this.drawChrome()
} }
// ─── Status ──────────────────────────────────────────── // ─── Status ────────────────────────────────────────────
markIdle(sessionName: string) { markIdle(sessionName: string) {
const pane = this.panes.find(p => p.session.name === sessionName) const pane = this.findPaneAcrossTabs(sessionName)
if (!pane) return if (!pane) return
if (pane.status !== "idle") { pane.status = "idle"; pane.statusSince = Date.now() } if (pane.status !== "idle") { pane.status = "idle"; pane.statusSince = Date.now() }
this.startFlash(sessionName) this.startFlash(sessionName)
@@ -458,7 +740,7 @@ export class DirectGridRenderer {
} }
markBusy(sessionName: string) { markBusy(sessionName: string) {
const pane = this.panes.find(p => p.session.name === sessionName) const pane = this.findPaneAcrossTabs(sessionName)
if (!pane) return if (!pane) return
if (pane.status !== "busy") { pane.status = "busy"; pane.statusSince = Date.now() } if (pane.status !== "busy") { pane.status = "busy"; pane.statusSince = Date.now() }
this.clearFlash(sessionName) this.clearFlash(sessionName)
@@ -466,13 +748,21 @@ export class DirectGridRenderer {
} }
clearMark(sessionName: string) { clearMark(sessionName: string) {
const pane = this.panes.find(p => p.session.name === sessionName) const pane = this.findPaneAcrossTabs(sessionName)
if (!pane) return if (!pane) return
pane.status = null; pane.statusSince = 0 pane.status = null; pane.statusSince = 0
this.clearFlash(sessionName) this.clearFlash(sessionName)
this.drawChrome() 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) { startFlash(sessionName: string) {
if (this.flashTimers.has(sessionName)) return if (this.flashTimers.has(sessionName)) return
const timer = setInterval(() => this.drawChrome(), 400) const timer = setInterval(() => this.drawChrome(), 400)
@@ -496,9 +786,10 @@ export class DirectGridRenderer {
const n = count ?? this.panes.length const n = count ?? this.panes.length
const termW = process.stdout.columns || 120 const termW = process.stdout.columns || 120
const termH = process.stdout.rows || 40 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 { cols, rows } = this.calcGrid(n)
const cellW = Math.floor(termW / cols) 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 }[] = [] const regions: { screenX: number, screenY: number, contentW: number, contentH: number }[] = []
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
@@ -507,7 +798,7 @@ export class DirectGridRenderer {
const contentW = cellW - 2 const contentW = cellW - 2
const contentH = cellH - 4 const contentH = cellH - 4
const screenX = gc * cellW + 2 const screenX = gc * cellW + 2
const screenY = 2 + gr * cellH + 3 const screenY = chromeTop + gr * cellH + 3
regions.push({ regions.push({
screenX, screenX,
screenY, screenY,
@@ -519,18 +810,55 @@ export class DirectGridRenderer {
} }
repositionAll() { repositionAll() {
if (this.isExpanded) {
// Expanded: give the expanded pane full screen area
const termW = process.stdout.columns || 120 const termW = process.stdout.columns || 120
const termH = process.stdout.rows || 40 const termH = process.stdout.rows || 40
const chromeTop = 3
if (this.isExpanded) {
// Fullscreen: expanded pane gets all space
const contentW = termW - 2 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]! 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)) resizeSession(pane.session.name, Math.max(contentW, 10), Math.max(contentH, 2))
resizeCapture(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}`) 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 { } else {
// Equal grid
const regions = this.calcPaneRegions() const regions = this.calcPaneRegions()
for (let i = 0; i < this.panes.length; i++) { for (let i = 0; i < this.panes.length; i++) {
const pane = this.panes[i]! const pane = this.panes[i]!
@@ -557,7 +885,10 @@ export class DirectGridRenderer {
destroyAll() { destroyAll() {
this.stop() this.stop()
this.panes = [] this.tabPanes.clear()
this._focusIndex = 0 this.tabFocus.clear()
this.tabExpanded.clear()
this.tabSoftExpand.clear()
this._activeTabId = -1
} }
} }

View File

@@ -22,13 +22,44 @@ export function switchToGrid() {
app.rawStdoutWrite("\x1b[?1000h") app.rawStdoutWrite("\x1b[?1000h")
app.rawStdoutWrite("\x1b[?1006h") app.rawStdoutWrite("\x1b[?1006h")
if (isNew || app.directGrid.paneCount === 0) { if (isNew || app.directGrid!.totalPaneCount === 0) {
app.directGrid.start() app.directGrid!.start()
} else { } 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() { export function resizeGridPanes() {
if (!app.directGrid || app.directGrid.paneCount === 0) return if (!app.directGrid || app.directGrid.paneCount === 0) return
app.directGrid.repositionAll() app.directGrid.repositionAll()

View File

@@ -81,6 +81,12 @@ async function main() {
height: "100%", height: "100%",
}) })
app.tabBarText = new TextRenderable(app.renderer, {
width: "100%",
height: 1,
flexShrink: 0,
})
app.headerText = new TextRenderable(app.renderer, { app.headerText = new TextRenderable(app.renderer, {
width: "100%", width: "100%",
height: 1, height: 1,
@@ -148,6 +154,7 @@ async function main() {
flexShrink: 0, flexShrink: 0,
}) })
app.mainBox.add(app.tabBarText)
app.mainBox.add(app.headerText) app.mainBox.add(app.headerText)
app.mainBox.add(app.colHeaderText) app.mainBox.add(app.colHeaderText)
app.mainBox.add(app.listBox) app.mainBox.add(app.listBox)

View File

@@ -1,8 +1,8 @@
import type { KeyEvent } from "@opentui/core" import type { KeyEvent } from "@opentui/core"
import { app } from "../lib/state" 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 { extractKeyboardInput, extractMouseEvents } from "./parser"
import { switchToGrid } from "../grid/view-switch" import { switchToGrid, switchToGridTab, createNewGridTab } from "../grid/view-switch"
import { doLaunch } from "../actions/launch" import { doLaunch } from "../actions/launch"
import { launchSelections } from "../actions/launcher" import { launchSelections } from "../actions/launcher"
import { loadSessions } from "../data/sessions" import { loadSessions } from "../data/sessions"
@@ -136,6 +136,49 @@ export function hitTestListRow(screenRow: number): number {
return -1 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 ──────────────────────────────────────────────────── // ─── Picker click ────────────────────────────────────────────────────
export function handlePickerClick(_col: number, screenRow: number) { export function handlePickerClick(_col: number, screenRow: number) {
@@ -146,6 +189,39 @@ export function handlePickerClick(_col: number, screenRow: number) {
updateAll() 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 ──────────────────────────────────────────────────────── // ─── Keyboard ────────────────────────────────────────────────────────
export async function handleKeypress(key: KeyEvent) { export async function handleKeypress(key: KeyEvent) {
@@ -284,10 +360,6 @@ export async function handleKeypress(key: KeyEvent) {
app.renderer.destroy() app.renderer.destroy()
return return
case "t":
if (app.directGrid && app.directGrid.paneCount > 0) switchToGrid()
return
default: default:
return return
} }
@@ -301,37 +373,82 @@ export async function handleKeypress(key: KeyEvent) {
export async function handleGridInput(rawSequence: string): Promise<boolean> { export async function handleGridInput(rawSequence: string): Promise<boolean> {
if (app.viewMode !== "grid" || !app.directGrid) return false if (app.viewMode !== "grid" || !app.directGrid) return false
if (rawSequence === "\x1b" && app.directGrid.isExpanded) { // Esc: collapse expanded/soft-expanded, or do nothing
app.directGrid.collapsePane() if (rawSequence === "\x1b") {
if (app.directGrid.isExpanded) { app.directGrid.collapsePane(); return true }
if (app.directGrid.isSoftExpanded) { app.directGrid.softCollapsePane(); return true }
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() switchToPicker()
return true 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 === "\x0e") { app.directGrid.focusNext(); return true }
if (rawSequence === "\x10") { app.directGrid.focusPrev(); return true } if (rawSequence === "\x10") { app.directGrid.focusPrev(); return true }
// Ctrl+F → open folder
if (rawSequence === "\x06") { if (rawSequence === "\x06") {
const pane = app.directGrid.focusedPane const pane = app.directGrid.focusedPane
if (pane) Bun.spawn(["open", pane.session.projectPath]) if (pane) Bun.spawn(["open", pane.session.projectPath])
return true return true
} }
// Ctrl+W → close pane (remove tab if last pane)
if (rawSequence === "\x17") { if (rawSequence === "\x17") {
const pane = app.directGrid.focusedPane const pane = app.directGrid.focusedPane
if (pane) { if (pane) {
if (app.directGrid.isExpanded) app.directGrid.collapsePane() if (app.directGrid.isExpanded) app.directGrid.collapsePane()
if (app.directGrid.isSoftExpanded) app.directGrid.softCollapsePane()
const { killSession } = await import("../pty/session-manager") const { killSession } = await import("../pty/session-manager")
app.directGrid.removePane(pane.session.name) app.directGrid.removePane(pane.session.name)
await killSession(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 return true
} }
// Page Up/Down → scroll
if (rawSequence === "\x1b[5~") { app.directGrid.sendScrollToFocused("up"); return true } if (rawSequence === "\x1b[5~") { app.directGrid.sendScrollToFocused("up"); return true }
if (rawSequence === "\x1b[6~") { app.directGrid.sendScrollToFocused("down"); return true } if (rawSequence === "\x1b[6~") { app.directGrid.sendScrollToFocused("down"); return true }
@@ -343,9 +460,10 @@ export async function handleGridInput(rawSequence: string): Promise<boolean> {
export function switchToPicker() { export function switchToPicker() {
app.viewMode = "picker" app.viewMode = "picker"
app.activeTabIndex = 0
if (app.directGrid) { if (app.directGrid) {
if (app.directGrid.selectMode) app.directGrid.exitSelectMode() 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() app.renderer.resume()
process.stdin.removeAllListeners("data") process.stdin.removeAllListeners("data")
@@ -376,7 +494,27 @@ function processGridInput(str: string) {
if (btn?.action === "max") dg.expandPane(btn.paneIndex) if (btn?.action === "max") dg.expandPane(btn.paneIndex)
else if (btn?.action === "min") dg.collapsePane() else if (btn?.action === "min") dg.collapsePane()
else if (btn?.action === "sel") dg.enterSelectMode() 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 continue
} }
} }
@@ -397,15 +535,24 @@ function processGridInput(str: string) {
// ─── Stdin: picker mode ────────────────────────────────────────────── // ─── Stdin: picker mode ──────────────────────────────────────────────
function processPickerInput(str: string) { function processPickerInput(str: string) {
// Ctrl+Space → toggle to grid // Ctrl+Space → toggle to last grid tab
if (str.includes("\x00") && app.directGrid && app.directGrid.paneCount > 0) { if (str.includes("\x00")) {
switchToGrid() 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 return
} }
}
const pickerMouse = extractMouseEvents(str) const pickerMouse = extractMouseEvents(str)
for (const me of pickerMouse) { 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 === 64) { if (app.cursor > 0) { app.cursor--; updateAll() } }
if (me.btn === 65) { if (app.cursor < app.displayRows.length - 1) { 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) const keyboard = extractKeyboardInput(str)
if (!keyboard) return if (!keyboard) return
// Check for Alt+digit and Alt+n/p before normal key processing
let ki = 0 let ki = 0
while (ki < keyboard.length) { 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 let matched = false
for (let len = Math.min(8, keyboard.length - ki); len >= 1; len--) { for (let len = Math.min(8, keyboard.length - ki); len >= 1; len--) {
const mapped = KEY_MAP[keyboard.slice(ki, ki + len)] const mapped = KEY_MAP[keyboard.slice(ki, ki + len)]

View File

@@ -65,9 +65,9 @@ export function extractKeyboardInput(data: string): string {
i += 3; continue i += 3; continue
} }
// \x1b` (ctrl+backtick) — keep as keyboard shortcut // Alt+digit (1-9) and Alt+letter (n, p) — keep as keyboard shortcuts
if (next === "`") { if ((next >= "1" && next <= "9") || next === "n" || next === "p") {
keyboard += "\x1b`" keyboard += data.slice(i, i + 2)
i += 2; continue i += 2; continue
} }

View File

@@ -7,6 +7,11 @@ import type { IdleSessionInfo } from "../data/monitor"
export type ViewMode = "picker" | "grid" export type ViewMode = "picker" | "grid"
export interface GridTab {
id: number
name: string
}
export const app = { export const app = {
// Config // Config
demoMode: Bun.argv.includes("--demo"), demoMode: Bun.argv.includes("--demo"),
@@ -36,9 +41,17 @@ export const app = {
mainBox: null as BoxRenderable | null, mainBox: null as BoxRenderable | null,
rawStdoutWrite: null as unknown as (s: string) => boolean, 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) // UI refs (set during init)
renderer: null as unknown as CliRenderer, renderer: null as unknown as CliRenderer,
headerText: null as unknown as TextRenderable, headerText: null as unknown as TextRenderable,
tabBarText: null as unknown as TextRenderable,
colHeaderText: null as unknown as TextRenderable, colHeaderText: null as unknown as TextRenderable,
listBox: null as unknown as ScrollBoxRenderable, listBox: null as unknown as ScrollBoxRenderable,
bottomRow: null as unknown as BoxRenderable, bottomRow: null as unknown as BoxRenderable,

View File

@@ -67,6 +67,38 @@ export function applySortMode() {
rebuildDisplayRows() 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 ───────────────────────────────────────────────── // ─── Header / Footer ─────────────────────────────────────────────────
export function updateHeader() { export function updateHeader() {
@@ -93,7 +125,7 @@ export function updateColumnHeaders() {
} }
export function updateFooter() { 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) { if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0) {
app.footerText.content = t` ${dim( 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 "↑↓ 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() { export function updateAll() {
if (app.destroyed) return if (app.destroyed) return
updateTabBar()
updateHeader() updateHeader()
rebuildList() rebuildList()
updateBottomPanel() updateBottomPanel()