From 1e105cd950390f30aba3823180b01f6e91cb1860 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 15:29:44 +0000 Subject: [PATCH] fix: buffer split mouse sequences, widen grid buttons, fix [object Object] in tab bar - Buffer partial escape sequences in stdin handler so split SGR mouse events don't leak garbage characters into PTY panes - Widen pane button hit areas from 1 char to 2-4 chars each; add title row click-to-expand; widen tab close/add buttons and pane list targets - Fix [object Object] rendering in picker tab bar and pane list caused by OpenTUI's t`` tag not handling StyledText interpolation; add st() helper that concatenates StyledText by merging chunk arrays Co-Authored-By: Claude Opus 4.6 --- src/components/direct-grid.ts | 212 ++++++++++++++++++++++++-------- src/data/session-store.ts | 119 ++++++++++++++++++ src/index.ts | 9 ++ src/input/handlers.ts | 221 +++++++++++++++++++++++++++++++--- src/lib/state.ts | 4 +- src/lib/styled.ts | 20 +++ src/lib/time.ts | 9 ++ src/lib/types.ts | 21 ++++ src/ui/panels.ts | 68 +++++++---- 9 files changed, 591 insertions(+), 92 deletions(-) create mode 100644 src/data/session-store.ts create mode 100644 src/lib/styled.ts diff --git a/src/components/direct-grid.ts b/src/components/direct-grid.ts index 340d0d4..88ee3f0 100644 --- a/src/components/direct-grid.ts +++ b/src/components/direct-grid.ts @@ -84,10 +84,15 @@ export class DirectGridRenderer { // Tab bar hit-test regions (col ranges for each tab) private tabBarHitRegions: { tabId: number, startCol: number, endCol: number }[] = [] + private tabCloseHitRegions: { tabId: number, startCol: number, endCol: number }[] = [] private tabBarAddBtnCol = -1 // Pane list hit-test regions (row 2) private paneListHitRegions: { tabId: number, paneIndex: number, startCol: number, endCol: number }[] = [] + // Pending close state + private _pendingCloseTabId = -1 + private _pendingCloseTimer: ReturnType | null = null + constructor(rawWrite: (s: string) => boolean) { this.writeRaw = rawWrite } @@ -164,6 +169,7 @@ export class DirectGridRenderer { } } this.repositionAll() + setTimeout(() => this.forceRedrawAll(), 100) this.titleTimer = setInterval(() => this.refreshTitles(), 1000) } @@ -253,6 +259,48 @@ export class DirectGridRenderer { this.tabSoftExpand.delete(tabId) } + // ─── Tab close (double-click confirm) ──────────────── + + get pendingCloseTabId() { return this._pendingCloseTabId } + + requestCloseTab(tabId: number): "pending" | "closed" { + if (this._pendingCloseTabId === tabId) { + // Second click — execute close + this.cancelPendingClose() + this.closeTab(tabId) + return "closed" + } + // First click — mark pending + this.cancelPendingClose() + this._pendingCloseTabId = tabId + this._pendingCloseTimer = setTimeout(() => { + this._pendingCloseTabId = -1 + this._pendingCloseTimer = null + this.drawChrome() + }, 2000) + this.drawChrome() + return "pending" + } + + closeTab(tabId: number): number { + const tabIdx = app.gridTabs.findIndex(t => t.id === tabId) + if (tabIdx < 0) return -1 + this.removeTab(tabId) + app.gridTabs.splice(tabIdx, 1) + return tabIdx + } + + cancelPendingClose() { + if (this._pendingCloseTimer) { + clearTimeout(this._pendingCloseTimer) + this._pendingCloseTimer = null + } + if (this._pendingCloseTabId !== -1) { + this._pendingCloseTabId = -1 + this.drawChrome() + } + } + setActiveTab(tabId: number) { if (this._activeTabId === tabId) return // Detach current tab's panes @@ -274,6 +322,7 @@ export class DirectGridRenderer { if (this.running) { this.writeRaw(CLEAR) this.repositionAll() + setTimeout(() => this.forceRedrawAll(), 100) } } @@ -292,23 +341,31 @@ export class DirectGridRenderer { } // Check if a click hit a button on the top border. Returns action + pane index. - checkButtonClick(col: number, row: number): { action: "max" | "min" | "sel" | "tab" | "newtab" | "panefocus", paneIndex: number, tabId?: number } | null { + // Hit areas are widened beyond the visible dot characters to make clicking easier. + checkButtonClick(col: number, row: number): { action: "max" | "min" | "sel" | "tab" | "newtab" | "panefocus" | "closetab" | "closepane", paneIndex: number, tabId?: number } | null { // Tab bar check (row 1) if (row === 1) { + // Check close buttons first — widened ±1 around the × character + for (const region of this.tabCloseHitRegions) { + if (col >= region.startCol - 1 && col <= region.endCol + 1) { + return { action: "closetab", paneIndex: -1, tabId: region.tabId } + } + } 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) { + // [+] button — widened ±1 + if (this.tabBarAddBtnCol > 0 && col >= this.tabBarAddBtnCol - 1 && col <= this.tabBarAddBtnCol + 3) { return { action: "newtab", paneIndex: -1 } } return null } - // Pane list check (row 2) + // Pane list check (row 2) — widened ±1 for easier clicks if (row === 2) { for (const region of this.paneListHitRegions) { - if (col >= region.startCol && col <= region.endCol) { + if (col >= region.startCol - 1 && col <= region.endCol + 1) { return { action: "panefocus", paneIndex: region.paneIndex, tabId: region.tabId } } } @@ -321,21 +378,30 @@ export class DirectGridRenderer { const bx = dp.screenX - 1 const by = dp.screenY - 3 const bw = dp.width + 2 - const btnRow = by - if (row !== btnRow) continue + // Top border row — traffic light buttons with widened hit areas + if (row === by) { + if (this.isExpanded) { + // Layout: ...─● ● ●─╮ (close=bw-7, min=bw-5, sel=bw-3) + // sel (rightmost): dot + border + corner = 3 chars + if (col >= bx + bw - 4 && col <= bx + bw - 1) return { action: "sel", paneIndex: i } + // min: dot ± 1 = 3 chars + if (col >= bx + bw - 6 && col <= bx + bw - 4) return { action: "min", paneIndex: i } + // close: border + dot = 2 chars (smaller to avoid accidents) + if (col >= bx + bw - 8 && col <= bx + bw - 6) return { action: "closepane", paneIndex: i } + } else { + // Layout: ...─● ●─╮ (close=bw-5, max=bw-3) + // max (rightmost): space + dot + border + corner = 4 chars + if (col >= bx + bw - 4 && col <= bx + bw - 1) return { action: "max", paneIndex: i } + // close: border + dot + space = 3 chars + if (col >= bx + bw - 7 && col <= bx + bw - 5) return { action: "closepane", paneIndex: i } + } + continue + } - if (this.isExpanded) { - const minRight = bx + bw - 2 - const minLeft = minRight - 4 - if (col >= minLeft && col <= minRight) return { action: "min", paneIndex: i } - const selRight = minLeft - 2 - const selLeft = selRight - 4 - if (col >= selLeft && col <= selRight) return { action: "sel", paneIndex: i } - } else { - const btnLeft = bx + bw - 7 - const btnRight = bx + bw - 3 - if (col >= btnLeft && col <= btnRight) return { action: "max", paneIndex: i } + // Title row (by+1) — click to expand/focus + if (row === by + 1 && !this.isExpanded) { + return { action: "max", paneIndex: i } } } return null @@ -374,6 +440,10 @@ export class DirectGridRenderer { this.drawPane(idx, lines) } this.repositionAll() + + // Force-redraw all panes after a short delay to catch initial frames + // that may have arrived before attach or been cleared by repositionAll + setTimeout(() => this.forceRedrawAll(), 200) } return info @@ -531,13 +601,13 @@ 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+space picker${RESET}` + headerRight = `${DIM}${hexFg("#f7768e")}●${RESET}${DIM} close │ ${hexFg("#e0af68")}●${RESET}${DIM} restore │ ${hexFg("#9ece6a")}●${RESET}${DIM} select │ 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}` + headerRight = `${DIM}click pane to focus │ ${hexFg("#9ece6a")}●${RESET}${DIM} fullscreen │ ctrl+e toggle${RESET}` } else { headerLeft = ` ${BOLD}cladm grid${RESET} — ${n} sessions │ focus: ${fi}/${n}` - headerRight = `${DIM}shift+arrows nav │ click ${BOLD}[MAX]${RESET}${DIM} expand │ ctrl+space picker │ ctrl+w close${RESET}` + headerRight = `${DIM}shift+arrows nav │ ${hexFg("#f7768e")}●${RESET}${DIM} close ${hexFg("#9ece6a")}●${RESET}${DIM} expand │ ctrl+space picker${RESET}` } out += `\x1b[3;1H\x1b[${termW}X${headerLeft} ${headerRight}` @@ -556,7 +626,7 @@ export class DirectGridRenderer { out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg("#9ece6a")}${BOLD}SELECT MODE${RESET} ${DIM}drag to select text │ cmd+c to copy │ press ${BOLD}Esc${RESET}${DIM} to exit${RESET}` } else if (this.isExpanded && pane) { const color = getColor(pane.session.colorIndex) - out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg(color)}▸${RESET} ${BOLD}${pane.session.projectName}${RESET} ${DIM}expanded │ Esc or [MIN] to restore grid${RESET}` + out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg(color)}▸${RESET} ${BOLD}${pane.session.projectName}${RESET} ${DIM}expanded │ Esc or ${hexFg("#e0af68")}●${RESET}${DIM} to restore grid${RESET}` } else if (pane) { const color = getColor(pane.session.colorIndex) const sid = pane.session.sessionId ? ` ${DIM}#${pane.session.sessionId.slice(0, 8)}${RESET}` : "" @@ -572,17 +642,27 @@ export class DirectGridRenderer { private drawTabBar(termW: number): string { this.tabBarHitRegions = [] + this.tabCloseHitRegions = [] this.tabBarAddBtnCol = -1 - let out = `\x1b[1;1H\x1b[${termW}X ` - let col = 2 + const RED_FG = hexFg("#f7768e") + const TAB_BG_ACTIVE = hexBg("#24283b") + const TAB_BORDER = hexFg("#3b4261") + + let out = `\x1b[1;1H\x1b[${termW}X` + let col = 1 // 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 │ " + if (pickerActive) { + out += `${TAB_BORDER}╭${RESET}${TAB_BG_ACTIVE} ${CYAN_FG}${BOLD}● Picker${RESET}${TAB_BG_ACTIVE} ${RESET}${TAB_BORDER}╮${RESET}` + } else { + out += ` ${DIM}○ Picker${RESET} ` + } + const pickerStart = pickerActive ? col + 1 : col + 1 + const pickerVisLen = pickerActive ? 10 : 10 + this.tabBarHitRegions.push({ tabId: -1, startCol: pickerStart, endCol: pickerStart + 7 }) + col += pickerVisLen // Grid tabs for (const tab of app.gridTabs) { @@ -590,25 +670,45 @@ export class DirectGridRenderer { const hasIdle = this.hasIdleInTab(tab.id) const count = this.getTabPaneCount(tab.id) const label = `${tab.name} (${count})` + const isPending = this._pendingCloseTabId === tab.id - 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 startCol = col + (isActive ? 2 : 1) // account for ╭ + space or just space const visLen = 2 + label.length // "● " + label - this.tabBarHitRegions.push({ tabId: tab.id, startCol, endCol: startCol + visLen - 1 }) - col += visLen + 3 // + " │ " + + // Close button text + const closeText = isPending ? `${RED_FG}${BOLD}●${RESET}` : `${DIM}×${RESET}` + const closeVisLen = 1 + + if (isActive) { + // Chrome-style raised active tab + out += `${TAB_BORDER}╭${RESET}${TAB_BG_ACTIVE} ${CYAN_FG}${BOLD}● ${label}${RESET}${TAB_BG_ACTIVE} ${closeText}${TAB_BG_ACTIVE} ${RESET}${TAB_BORDER}╮${RESET}` + // ╭ + space + ● label + space + × + space + ╮ + const totalVis = 1 + 1 + visLen + 1 + closeVisLen + 1 + 1 + this.tabBarHitRegions.push({ tabId: tab.id, startCol, endCol: startCol + visLen - 1 }) + const closeStartCol = startCol + visLen + 1 + this.tabCloseHitRegions.push({ tabId: tab.id, startCol: closeStartCol, endCol: closeStartCol + closeVisLen - 1 }) + col += totalVis + } else { + // Inactive tab — flat, no border + let indicator: string + if (hasIdle) { + indicator = `${YELLOW_FG}◉ ${label}${RESET}` + } else { + indicator = `${DIM}○ ${label}${RESET}` + } + out += ` ${indicator} ${closeText} ${DIM}│${RESET}` + // space + ● label + space + × + space + │ + const totalVis = 1 + visLen + 1 + closeVisLen + 1 + 1 + this.tabBarHitRegions.push({ tabId: tab.id, startCol, endCol: startCol + visLen - 1 }) + const closeStartCol = startCol + visLen + 1 + this.tabCloseHitRegions.push({ tabId: tab.id, startCol: closeStartCol, endCol: closeStartCol + closeVisLen - 1 }) + col += totalVis + } } // [+] button - out += `${DIM}[+]${RESET}` + out += ` ${DIM}[+]${RESET}` + col += 1 this.tabBarAddBtnCol = col col += 3 @@ -682,16 +782,23 @@ export class DirectGridRenderer { let out = "" - // Top border with buttons + // Top border with traffic-light buttons (macOS style: close, minimize, expand) + const RED_DOT = `${hexFg("#f7768e")}●${RESET}` // close pane + const YELLOW_DOT = `${hexFg("#e0af68")}●${RESET}` // minimize / collapse + const GREEN_DOT = `${hexFg("#9ece6a")}●${RESET}` // expand / maximize + const DIM_DOT = `${DIM}●${RESET}` + let btnSection: string let btnVisibleLen: number if (this.isExpanded) { - 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 + // Expanded: show close · minimize · select(green means select mode) + const selDot = this._selectMode ? `${hexFg("#9ece6a")}${BOLD}●${RESET}` : DIM_DOT + btnSection = `${borderColor}${hz}${RESET}${RED_DOT} ${YELLOW_DOT} ${selDot}${borderColor}` + btnVisibleLen = 1 + 1 + 1 + 1 + 1 + 1 + 1 // ─● ● ● } else { - btnSection = `${RESET}${DIM}[MAX]${RESET}${borderColor}` - btnVisibleLen = 5 + // Grid: show close · expand + btnSection = `${borderColor}${hz}${RESET}${RED_DOT} ${GREEN_DOT}${borderColor}` + btnVisibleLen = 1 + 1 + 1 + 1 // ─● ● } const hzFill = Math.max(0, bw - 2 - btnVisibleLen - 1) out += `\x1b[${by};${bx}H${borderColor}${tl}${hz.repeat(hzFill)}${btnSection}${hz}${tr}${RESET}` @@ -740,6 +847,17 @@ export class DirectGridRenderer { this.writeRaw(SYNC_START + frame + SYNC_END) } + forceRedrawAll() { + if (!this.running) return + for (let i = 0; i < this.panes.length; i++) { + const pane = this.panes[i]! + resetHash(`dp_${pane.session.name}`) + const frame = getLatestFrame(pane.session.name) + if (frame) this.drawPane(i, frame.lines) + } + this.drawChrome() + } + // ─── Input ───────────────────────────────────────────── sendInputToFocused(rawSequence: string) { diff --git a/src/data/session-store.ts b/src/data/session-store.ts new file mode 100644 index 0000000..da35b65 --- /dev/null +++ b/src/data/session-store.ts @@ -0,0 +1,119 @@ +import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "fs" +import { join, dirname } from "path" +import { app } from "../lib/state" +import type { SavedSession, SavedTab, SavedPane } from "../lib/types" +import { createSession } from "../pty/session-manager" +import { ensureGridView, switchToGridTab } from "../grid/view-switch" + +const SESSION_PATH = join(process.env.HOME ?? "", ".config", "cladm", "session.json") + +export function extractSessionState(): SavedSession | null { + const dg = app.directGrid + if (!dg || app.gridTabs.length === 0) return null + + const tabs: SavedTab[] = [] + for (const tab of app.gridTabs) { + const paneInfos = dg.getTabPanes(tab.id) + const panes: SavedPane[] = [] + for (const p of paneInfos) { + if (!p.session.alive) continue + panes.push({ + projectPath: p.session.projectPath, + projectName: p.session.projectName, + sessionId: p.session.sessionId, + targetBranch: p.session.targetBranch, + }) + } + if (panes.length > 0) { + tabs.push({ id: tab.id, name: tab.name, panes }) + } + } + + if (tabs.length === 0) return null + + const activeIdx = app.gridTabs.findIndex(t => t.id === dg.activeTabId) + return { + version: 1, + savedAt: Date.now(), + activeTabIndex: Math.max(0, activeIdx), + nextTabId: app.nextTabId, + tabs, + } +} + +export function saveSessionSync(data: SavedSession): void { + const dir = dirname(SESSION_PATH) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + writeFileSync(SESSION_PATH, JSON.stringify(data, null, 2)) +} + +export async function loadSavedSession(): Promise { + try { + const file = Bun.file(SESSION_PATH) + if (!await file.exists()) return null + const data = await file.json() as SavedSession + if (data.version !== 1 || !Array.isArray(data.tabs)) return null + return data + } catch { + return null + } +} + +export function deleteSavedSession(): void { + try { unlinkSync(SESSION_PATH) } catch {} +} + +export async function restoreSession(saved: SavedSession, useResume: boolean): Promise { + ensureGridView() + + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + + let firstTabId: number | null = null + + for (const savedTab of saved.tabs) { + const tabId = app.nextTabId++ + const tab = { id: tabId, name: savedTab.name } + app.gridTabs.push(tab) + app.directGrid!.addTab(tab) + if (firstTabId === null) firstTabId = tabId + + const validPanes = savedTab.panes.filter(p => existsSync(p.projectPath)) + const n = validPanes.length + const cols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : 3 + const rows = Math.ceil(n / cols) + const paneW = Math.max(Math.floor(termW / cols) - 2, 20) + const paneH = Math.max(Math.floor((termH - 2) / rows) - 4, 6) + + for (const pane of validPanes) { + const session = await createSession({ + projectPath: pane.projectPath, + projectName: pane.projectName, + sessionId: useResume ? pane.sessionId : undefined, + targetBranch: pane.targetBranch, + width: paneW, + height: paneH, + }) + await app.directGrid!.addPane(session, tabId) + } + } + + // Sort tabs by name + app.gridTabs.sort((a, b) => { + const na = parseInt(a.name.replace(/\D/g, "")) || 0 + const nb = parseInt(b.name.replace(/\D/g, "")) || 0 + return na - nb + }) + + // Switch to saved active tab + const targetIdx = Math.min(saved.activeTabIndex, app.gridTabs.length - 1) + if (targetIdx >= 0 && app.gridTabs[targetIdx]) { + switchToGridTab(app.gridTabs[targetIdx].id) + } else if (firstTabId !== null) { + switchToGridTab(firstTabId) + } + + deleteSavedSession() + app.savedSession = null + app.restoreMode = null +} diff --git a/src/index.ts b/src/index.ts index cff277f..1251798 100755 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ import { app } from "./lib/state" import { updateAll, rebuildDisplayRows, updateUsagePanel, updateColumnHeaders } from "./ui/panels" import { stdinHandler } from "./input/handlers" import { resizeGridPanes } from "./grid/view-switch" +import { loadSavedSession, extractSessionState, saveSessionSync } from "./data/session-store" function refreshMockSessions(projects: Project[]) { generateMockActiveSessions(projects) @@ -55,6 +56,9 @@ async function main() { app.sortedIndices = app.projects.map((_, i) => i) rebuildDisplayRows() + // Load saved session for restore hint + app.savedSession = await loadSavedSession() + // Save raw stdout.write BEFORE OpenTUI intercepts it app.rawStdoutWrite = process.stdout.write.bind(process.stdout) as (s: string) => boolean @@ -64,6 +68,11 @@ async function main() { useMouse: false, onDestroy: () => { app.destroyed = true + // Save session state before cleanup + try { + const state = extractSessionState() + if (state) saveSessionSync(state) + } catch {} if (app.monitorInterval) { clearInterval(app.monitorInterval); app.monitorInterval = null } if (app.directGrid) app.directGrid.destroyAll() stopAllCaptures() diff --git a/src/input/handlers.ts b/src/input/handlers.ts index 35ef085..65b7ea6 100644 --- a/src/input/handlers.ts +++ b/src/input/handlers.ts @@ -11,6 +11,7 @@ import { generateMockSessions, generateMockBranches } from "../data/mock" import { focusTerminalByPath, populateMockSessionStatus } from "../data/monitor" import { stopAllCaptures } from "../pty/capture" import type { DisplayRow } from "../lib/types" +import { extractSessionState, saveSessionSync, restoreSession } from "../data/session-store" // ─── Constants ─────────────────────────────────────────────────────── @@ -216,27 +217,62 @@ export function handlePickerClick(_col: number, screenRow: number) { 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 + // Hit test against tab bar positions — Chrome-style layout + // Picker: ╭ ● Picker ╮ = cols 1..10 (active) or ○ Picker = cols 1..10 + let c = 1 + const pickerEnd = c + 10 if (col >= c && col <= pickerEnd) return false // already on picker - c += 11 + c = 11 for (const tab of app.gridTabs) { const count = app.directGrid?.getTabPaneCount(tab.id) ?? 0 + const isActive = app.viewMode === "grid" && app.directGrid?.activeTabId === tab.id const label = `${tab.name} (${count})` - const visLen = 2 + label.length - if (col >= c && col < c + visLen) { - switchToGridTab(tab.id) - return true + const visLen = 2 + label.length // "● " + label + + const dg = app.directGrid + + if (isActive) { + // Active: ╭ ● label × ╮ → c+1=╭, c+2=space, c+3..=● label, then close, ╮ + const labelStart = c + 2 + const labelEnd = labelStart + visLen - 1 + const closeCol = labelEnd + 2 // space + × position + const totalVis = 1 + 1 + visLen + 1 + 1 + 1 + 1 // ╭ + sp + label + sp + × + sp + ╮ + + if (col === closeCol && dg) { + const result = dg.requestCloseTab(tab.id) + if (result === "closed") updateAll() + else { updateTabBar(); app.renderer.requestRender() } + return true + } + if (col >= labelStart && col <= labelEnd) { + switchToGridTab(tab.id) + return true + } + c += totalVis + } else { + // Inactive: sp ● label sp × sp │ → 1 + visLen + 1 + 1 + 1 + 1 + const labelStart = c + 1 + const labelEnd = labelStart + visLen - 1 + const closeCol = labelEnd + 2 + const totalVis = 1 + visLen + 1 + 1 + 1 + 1 // sp + label + sp + × + sp + │ + + if (col === closeCol && dg) { + const result = dg.requestCloseTab(tab.id) + if (result === "closed") updateAll() + else { updateTabBar(); app.renderer.requestRender() } + return true + } + if (col >= labelStart && col <= labelEnd) { + switchToGridTab(tab.id) + return true + } + c += totalVis } - c += visLen + 3 } // [+] button - if (col >= c && col <= c + 2) { + if (col >= c + 1 && col <= c + 3) { createNewGridTab() return true } @@ -379,10 +415,48 @@ export async function handleKeypress(key: KeyEvent) { break } - case "q": + case "r": { + if (app.restoreMode === "pending") { + // Second press: restore with resume + const saved = app.savedSession + if (saved) { + app.restoreMode = null + await restoreSession(saved, true) + return + } + } else if (app.savedSession) { + app.restoreMode = "pending" + } + break + } + + case "R": { + if (app.restoreMode === "pending") { + // Shift+R: restore fresh (no sessionIds) + const saved = app.savedSession + if (saved) { + app.restoreMode = null + await restoreSession(saved, false) + return + } + } + break + } + case "escape": + if (app.restoreMode === "pending") { + app.restoreMode = null + break + } + // fall through to quit + case "q": app.destroyed = true if (app.monitorInterval) clearInterval(app.monitorInterval) + // Save session before exit + try { + const state = extractSessionState() + if (state) saveSessionSync(state) + } catch {} stopAllCaptures() process.stdout.write("\x1b[?1006l") process.stdout.write("\x1b[?1000l") @@ -520,10 +594,49 @@ function processGridInput(str: string) { if (me.btn === 65) { dg.sendScrollToFocused("down", 3); continue } if (me.btn === 0 && !me.release) { const btn = dg.checkButtonClick(me.col, me.row) - if (btn?.action === "max") dg.expandPane(btn.paneIndex) - else if (btn?.action === "min") dg.collapsePane() - else if (btn?.action === "sel") dg.enterSelectMode() + if (btn?.action === "closetab" && btn.tabId !== undefined) { + const result = dg.requestCloseTab(btn.tabId) + if (result === "closed") { + // Tab was closed — switch to adjacent or picker + if (app.gridTabs.length > 0) { + const currentTabId = dg.activeTabId + if (btn.tabId === currentTabId) { + // Closed the active tab — switch to first available + switchToGridTab(app.gridTabs[0].id) + } else { + dg.drawChrome() + } + } else { + switchToPicker() + } + } + } + else if (btn?.action === "closepane") { + dg.cancelPendingClose() + const pane = dg.paneCount > btn.paneIndex ? dg.getTabPanes(dg.activeTabId)[btn.paneIndex] : null + if (pane) { + if (dg.isExpanded) dg.collapsePane() + if (dg.isSoftExpanded) dg.softCollapsePane() + dg.removePane(pane.session.name) + if (dg.paneCount === 0) { + const currentTabId = dg.activeTabId + const tabIdx = app.gridTabs.findIndex(t => t.id === currentTabId) + dg.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() + } + } + } + } + else if (btn?.action === "max") { dg.cancelPendingClose(); dg.expandPane(btn.paneIndex) } + else if (btn?.action === "min") { dg.cancelPendingClose(); dg.collapsePane() } + else if (btn?.action === "sel") { dg.cancelPendingClose(); dg.enterSelectMode() } else if (btn?.action === "tab") { + dg.cancelPendingClose() if (btn.tabId === -1) { // Switch to picker app.lastGridTabIndex = app.gridTabs.findIndex(t => t.id === dg.activeTabId) @@ -532,14 +645,16 @@ function processGridInput(str: string) { switchToGridTab(btn.tabId) } } - else if (btn?.action === "newtab") createNewGridTab() + else if (btn?.action === "newtab") { dg.cancelPendingClose(); createNewGridTab() } else if (btn?.action === "panefocus" && btn.tabId !== undefined) { + dg.cancelPendingClose() // Click on pane name in pane list → switch to that tab and focus the pane switchToGridTab(btn.tabId) dg.setFocus(btn.paneIndex) if (app.clickExpand) dg.softExpandPane(btn.paneIndex) } else { + dg.cancelPendingClose() // Pane body click if (app.clickExpand && !dg.isExpanded) { const clickedIdx = dg.getPaneIndexAtClick(me.col, me.row) @@ -630,10 +745,76 @@ function processPickerInput(str: string) { } } -// ─── Stdin entry point ─────────────────────────────────────────────── +// ─── Stdin buffering ───────────────────────────────────────────────── +// SGR mouse sequences (\x1b[ | null = null + +function dispatch(str: string) { if (app.viewMode === "grid" && app.directGrid) processGridInput(str) else processPickerInput(str) } + +function flushPending() { + _timer = null + if (_pending) { + const p = _pending + _pending = "" + dispatch(p) + } +} + +// Returns index of a trailing partial escape sequence, or -1 if complete. +function trailingPartialEsc(data: string): number { + for (let i = data.length - 1; i >= 0 && i >= data.length - 30; i--) { + if (data.charCodeAt(i) !== 0x1b) continue + const ch = data[i + 1] + // Lone ESC at end + if (ch === undefined) return i + // CSI: \x1b[ — check for final byte + if (ch === "[") { + let j = i + 2 + while (j < data.length && data.charCodeAt(j) >= 0x30 && data.charCodeAt(j) <= 0x3f) j++ + while (j < data.length && data.charCodeAt(j) >= 0x20 && data.charCodeAt(j) <= 0x2f) j++ + if (j >= data.length) return i // no final byte yet — partial + continue + } + // OSC/DCS/APC/PM — need ST terminator + if (ch === "]" || ch === "P" || ch === "_" || ch === "^") { + let terminated = false + for (let j = i + 2; j < data.length; j++) { + if (data[j] === "\x07") { terminated = true; break } + if (data[j] === "\x1b" && data[j + 1] === "\\") { terminated = true; break } + } + if (!terminated) return i + continue + } + // SS3 (\x1bO) needs one more byte + if (ch === "O" && i + 2 >= data.length) return i + continue + } + return -1 +} + +// ─── Stdin entry point ─────────────────────────────────────────────── + +export function stdinHandler(data: string | Buffer) { + if (_timer) { clearTimeout(_timer); _timer = null } + const str = typeof data === "string" ? data : data.toString("utf8") + const full = _pending + str + _pending = "" + + const idx = trailingPartialEsc(full) + if (idx >= 0) { + _pending = full.slice(idx) + const ready = full.slice(0, idx) + if (ready) dispatch(ready) + _timer = setTimeout(flushPending, 8) + return + } + + dispatch(full) +} diff --git a/src/lib/state.ts b/src/lib/state.ts index eee9487..c13fba0 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -1,6 +1,6 @@ import type { CliRenderer } from "@opentui/core" import type { BoxRenderable, TextRenderable, ScrollBoxRenderable } from "@opentui/core" -import type { Project, DisplayRow } from "./types" +import type { Project, DisplayRow, SavedSession } from "./types" import type { DirectGridRenderer } from "../components/direct-grid" import type { UsageSummary } from "../data/usage" import type { IdleSessionInfo } from "../data/monitor" @@ -47,6 +47,8 @@ export const app = { nextTabId: 1, // auto-increment for tab ids clickExpand: true, // click-to-expand feature toggle lastGridTabIndex: 0, // last active grid tab for Ctrl+Space toggle + savedSession: null as SavedSession | null, + restoreMode: null as "pending" | null, // UI refs (set during init) renderer: null as unknown as CliRenderer, diff --git a/src/lib/styled.ts b/src/lib/styled.ts new file mode 100644 index 0000000..36f39c3 --- /dev/null +++ b/src/lib/styled.ts @@ -0,0 +1,20 @@ +import { StyledText } from "@opentui/core" + +type Chunk = { __isChunk: true; text: string; attributes: number; fg?: unknown; bg?: unknown } +type StyledPart = string | StyledText | Chunk + +// Concatenate styled text parts into a single StyledText. +// OpenTUI's t`` tag doesn't handle StyledText interpolation — it calls +// toString() which produces "[object Object]". This helper merges chunks +// from multiple t`` results, TextChunks, and plain strings. +export function st(...parts: StyledPart[]): StyledText { + const chunks: Chunk[] = [] + for (const p of parts) { + if (p instanceof StyledText) chunks.push(...p.chunks) + else if (p && typeof p === "object" && "__isChunk" in p) chunks.push(p as Chunk) + else if (typeof p === "string") { + if (p.length > 0) chunks.push({ __isChunk: true, text: p, attributes: 0 } as Chunk) + } + } + return new StyledText(chunks) +} diff --git a/src/lib/time.ts b/src/lib/time.ts index 18078b6..7a88517 100644 --- a/src/lib/time.ts +++ b/src/lib/time.ts @@ -20,6 +20,15 @@ export function elapsedCompact(ms: number): string { return `${Math.floor(sec / 86400)}d` } +export function timeAgoShort(ms: number): string { + if (!ms) return "" + const diff = Math.floor((Date.now() - ms) / 1000) + if (diff < 60) return "0m ago" + if (diff < 3600) return `${Math.floor(diff / 60)}m ago` + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago` + return `${Math.floor(diff / 86400)}d ago` +} + export function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes}B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` diff --git a/src/lib/types.ts b/src/lib/types.ts index 75110f5..06b6bda 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -46,3 +46,24 @@ export interface DisplayRow { sessionIndex?: number branchName?: string } + +export interface SavedPane { + projectPath: string + projectName: string + sessionId?: string + targetBranch?: string +} + +export interface SavedTab { + id: number + name: string + panes: SavedPane[] +} + +export interface SavedSession { + version: 1 + savedAt: number + activeTabIndex: number + nextTabId: number + tabs: SavedTab[] +} diff --git a/src/ui/panels.ts b/src/ui/panels.ts index ce2218c..5e8a3cd 100644 --- a/src/ui/panels.ts +++ b/src/ui/panels.ts @@ -9,12 +9,14 @@ import { yellow, cyan, magenta, + red, } from "@opentui/core" +import { st } from "../lib/styled" import { app } from "../lib/state" import { CURSOR_BG, ACTIVE_BG, ACCENT } from "../lib/theme" import { getSessionStatus, getIdleSessions } from "../data/monitor" import { formatCost, formatWindow, makeBar, pct, PLAN_LIMITS } from "../data/usage" -import { timeAgo, formatSize, elapsedCompact } from "../lib/time" +import { timeAgo, formatSize, elapsedCompact, timeAgoShort } from "../lib/time" import { fmtProjectRow, fmtSessionRow, fmtNewSessionRow, fmtBranchRow, fmtSyncIndicator } from "./formatters" // ─── Display rows ──────────────────────────────────────────────────── @@ -81,7 +83,7 @@ export function updatePaneList() { return } - let content = t` ` + const parts: Parameters = [t` `] let first = true for (const tab of app.gridTabs) { const tabPanes = app.directGrid.getTabPanes(tab.id) @@ -93,48 +95,50 @@ export function updatePaneList() { const short = name.length > 14 ? name.slice(0, 12) + "…" : name const isFocused = app.directGrid!.activeTabId === tab.id && app.directGrid!.focusIndex === pi - if (!first) content = t`${content}${dim(" · ")}` - if (isFocused) { - content = t`${content}${bold(short)}` - } else { - content = t`${content}${dim(short)}` - } + if (!first) parts.push(dim(" · ")) + parts.push(isFocused ? bold(short) : dim(short)) first = false } - content = t`${content}${dim(" │ ")}` + parts.push(dim(" │ ")) first = true } - app.paneListText.content = content + app.paneListText.content = st(...parts) } 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")}` + const sep = dim(" │ ") - // Start with picker - let content = t` ${pickerTab}` + // Chrome-style: active tab gets visual emphasis + const parts: Parameters = [] + if (pickerActive) { + parts.push(t` ${dim("╭")} ${cyan("●")} ${bold("Picker")} ${dim("╮")}`) + } else { + parts.push(t` ${dim("○ Picker")} `) + } // 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 isPending = app.directGrid?.pendingCloseTabId === tab.id const label = `${tab.name} (${count})` + const closeBtn = isPending ? t` ${red(bold("●"))}` : t` ${dim("×")}` + if (isActive) { - content = t`${content}${sep}${cyan("●")} ${bold(label)}` + parts.push(dim("╭"), t` ${cyan("●")} ${bold(label)}`, closeBtn, t` ${dim("╮")}`) } else if (hasIdle) { - content = t`${content}${sep}${yellow("◉")} ${label}` + parts.push(t` ${yellow("◉")} ${label}`, closeBtn, " ", sep) } else { - content = t`${content}${sep}${dim("○ " + label)}` + parts.push(t` ${dim("○ " + label)}`, closeBtn, " ", sep) } } - content = t`${content}${sep}${dim("[+]")}` - app.tabBarText.content = content + parts.push(t` ${dim("[+]")}`) + app.tabBarText.content = st(...parts) } // ─── Header / Footer ───────────────────────────────────────────────── @@ -167,13 +171,28 @@ export function updateColumnHeaders() { export function updateFooter() { const gridHint = app.directGrid && app.directGrid.totalPaneCount > 0 ? " │ ^space grid" : "" + + // Restore mode: show choice prompt + if (app.restoreMode === "pending") { + app.footerText.content = t` ${yellow("Restore session?")} ${dim("r resume │ R fresh │ esc cancel")}` + return + } + + // Saved session hint + let restoreHint = "" + if (app.savedSession) { + const ago = timeAgoShort(app.savedSession.savedAt) + const paneCount = app.savedSession.tabs.reduce((sum, t) => sum + t.panes.length, 0) + restoreHint = ` │ r restore (${paneCount}p, ${ago})` + } + 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 + "↑↓ nav │ tab/shift-tab idle select │ enter focus │ i preview │ space select │ a all │ n none │ s sort │ q quit" + gridHint + restoreHint )}` } else { app.footerText.content = t` ${dim( - "↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter grid │ o external │ q quit" + gridHint + "↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter grid │ o external │ q quit" + gridHint + restoreHint )}` } } @@ -310,9 +329,10 @@ export function updatePreview() { const selNote = selBranch === br.name ? t` ${green("Selected")} — will launch with: ${dim(`-p "switch to branch ${br.name}, stash if needed"`)}` : t` ${dim("Press space to select this branch for launch")}` - app.previewText.content = t` ${bold("Branch:")} ${magenta(br.name)} ${dim("Sync:")} ${sync} + app.previewText.content = st( + t` ${bold("Branch:")} ${magenta(br.name)} ${dim("Sync:")} ${sync} ${dim("Last commit:")} ${br.lastCommitAge} — ${br.lastCommitMsg} -${selNote}` +`, selNote) } } else { app.previewText.content = t` ${green("Start a new Claude session")} in ${bold(project.name)}