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 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-28 15:29:44 +00:00
parent c722112a7f
commit 1e105cd950
9 changed files with 591 additions and 92 deletions

View File

@@ -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<typeof setTimeout> | 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) {
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 }
// 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 {
const btnLeft = bx + bw - 7
const btnRight = bx + bw - 3
if (col >= btnLeft && col <= btnRight) return { action: "max", paneIndex: i }
// 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
}
// 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
// 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 })
col += visLen + 3 // + " │ "
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) {

119
src/data/session-store.ts Normal file
View File

@@ -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<SavedSession | null> {
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<void> {
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
}

View File

@@ -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()

View File

@@ -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) {
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 += visLen + 3
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
}
}
// [+] 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[<btn;col;rowM) can be split across stdin
// data events. Buffer partial escape sequences so fragments don't leak
// into the PTY as garbage characters.
export function stdinHandler(data: string | Buffer) {
const str = typeof data === "string" ? data : data.toString("utf8")
let _pending = ""
let _timer: ReturnType<typeof setTimeout> | 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)
}

View File

@@ -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,

20
src/lib/styled.ts Normal file
View File

@@ -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)
}

View File

@@ -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`

View File

@@ -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[]
}

View File

@@ -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<typeof st> = [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<typeof st> = []
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)}