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:
@@ -84,10 +84,15 @@ export class DirectGridRenderer {
|
|||||||
|
|
||||||
// Tab bar hit-test regions (col ranges for each tab)
|
// Tab bar hit-test regions (col ranges for each tab)
|
||||||
private tabBarHitRegions: { tabId: number, startCol: number, endCol: number }[] = []
|
private tabBarHitRegions: { tabId: number, startCol: number, endCol: number }[] = []
|
||||||
|
private tabCloseHitRegions: { tabId: number, startCol: number, endCol: number }[] = []
|
||||||
private tabBarAddBtnCol = -1
|
private tabBarAddBtnCol = -1
|
||||||
// Pane list hit-test regions (row 2)
|
// Pane list hit-test regions (row 2)
|
||||||
private paneListHitRegions: { tabId: number, paneIndex: number, startCol: number, endCol: number }[] = []
|
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) {
|
constructor(rawWrite: (s: string) => boolean) {
|
||||||
this.writeRaw = rawWrite
|
this.writeRaw = rawWrite
|
||||||
}
|
}
|
||||||
@@ -164,6 +169,7 @@ export class DirectGridRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.repositionAll()
|
this.repositionAll()
|
||||||
|
setTimeout(() => this.forceRedrawAll(), 100)
|
||||||
this.titleTimer = setInterval(() => this.refreshTitles(), 1000)
|
this.titleTimer = setInterval(() => this.refreshTitles(), 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,6 +259,48 @@ export class DirectGridRenderer {
|
|||||||
this.tabSoftExpand.delete(tabId)
|
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) {
|
setActiveTab(tabId: number) {
|
||||||
if (this._activeTabId === tabId) return
|
if (this._activeTabId === tabId) return
|
||||||
// Detach current tab's panes
|
// Detach current tab's panes
|
||||||
@@ -274,6 +322,7 @@ export class DirectGridRenderer {
|
|||||||
if (this.running) {
|
if (this.running) {
|
||||||
this.writeRaw(CLEAR)
|
this.writeRaw(CLEAR)
|
||||||
this.repositionAll()
|
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.
|
// 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)
|
// Tab bar check (row 1)
|
||||||
if (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) {
|
for (const region of this.tabBarHitRegions) {
|
||||||
if (col >= region.startCol && col <= region.endCol) {
|
if (col >= region.startCol && col <= region.endCol) {
|
||||||
return { action: "tab", paneIndex: -1, tabId: region.tabId }
|
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 { action: "newtab", paneIndex: -1 }
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
// Pane list check (row 2)
|
// Pane list check (row 2) — widened ±1 for easier clicks
|
||||||
if (row === 2) {
|
if (row === 2) {
|
||||||
for (const region of this.paneListHitRegions) {
|
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 }
|
return { action: "panefocus", paneIndex: region.paneIndex, tabId: region.tabId }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -321,21 +378,30 @@ export class DirectGridRenderer {
|
|||||||
const bx = dp.screenX - 1
|
const bx = dp.screenX - 1
|
||||||
const by = dp.screenY - 3
|
const by = dp.screenY - 3
|
||||||
const bw = dp.width + 2
|
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) {
|
// Title row (by+1) — click to expand/focus
|
||||||
const minRight = bx + bw - 2
|
if (row === by + 1 && !this.isExpanded) {
|
||||||
const minLeft = minRight - 4
|
return { action: "max", paneIndex: i }
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -374,6 +440,10 @@ export class DirectGridRenderer {
|
|||||||
this.drawPane(idx, lines)
|
this.drawPane(idx, lines)
|
||||||
}
|
}
|
||||||
this.repositionAll()
|
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
|
return info
|
||||||
@@ -531,13 +601,13 @@ 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+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) {
|
} else if (this.isSoftExpanded) {
|
||||||
headerLeft = ` ${BOLD}cladm grid${RESET} — ${hexFg("#bb9af7")}${BOLD}FOCUS${RESET} │ ${fi}/${n}`
|
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 {
|
} 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 │ 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}`
|
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}`
|
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) {
|
} else if (this.isExpanded && pane) {
|
||||||
const color = getColor(pane.session.colorIndex)
|
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) {
|
} 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}` : ""
|
||||||
@@ -572,17 +642,27 @@ export class DirectGridRenderer {
|
|||||||
|
|
||||||
private drawTabBar(termW: number): string {
|
private drawTabBar(termW: number): string {
|
||||||
this.tabBarHitRegions = []
|
this.tabBarHitRegions = []
|
||||||
|
this.tabCloseHitRegions = []
|
||||||
this.tabBarAddBtnCol = -1
|
this.tabBarAddBtnCol = -1
|
||||||
|
|
||||||
let out = `\x1b[1;1H\x1b[${termW}X `
|
const RED_FG = hexFg("#f7768e")
|
||||||
let col = 2
|
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)
|
// Picker tab (id = -1, meaning: switch to picker)
|
||||||
const pickerActive = app.viewMode === "picker"
|
const pickerActive = app.viewMode === "picker"
|
||||||
const pickerLabel = pickerActive ? `${CYAN_FG}${BOLD}● Picker${RESET}` : `${DIM}○ Picker${RESET}`
|
if (pickerActive) {
|
||||||
out += pickerLabel + ` ${DIM}│${RESET} `
|
out += `${TAB_BORDER}╭${RESET}${TAB_BG_ACTIVE} ${CYAN_FG}${BOLD}● Picker${RESET}${TAB_BG_ACTIVE} ${RESET}${TAB_BORDER}╮${RESET}`
|
||||||
this.tabBarHitRegions.push({ tabId: -1, startCol: col, endCol: col + 7 })
|
} else {
|
||||||
col += 11 // "● Picker │ "
|
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
|
// Grid tabs
|
||||||
for (const tab of app.gridTabs) {
|
for (const tab of app.gridTabs) {
|
||||||
@@ -590,25 +670,45 @@ export class DirectGridRenderer {
|
|||||||
const hasIdle = this.hasIdleInTab(tab.id)
|
const hasIdle = this.hasIdleInTab(tab.id)
|
||||||
const count = this.getTabPaneCount(tab.id)
|
const count = this.getTabPaneCount(tab.id)
|
||||||
const label = `${tab.name} (${count})`
|
const label = `${tab.name} (${count})`
|
||||||
|
const isPending = this._pendingCloseTabId === tab.id
|
||||||
|
|
||||||
let tabText: string
|
const startCol = col + (isActive ? 2 : 1) // account for ╭ + space or just space
|
||||||
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
|
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
|
// [+] button
|
||||||
out += `${DIM}[+]${RESET}`
|
out += ` ${DIM}[+]${RESET}`
|
||||||
|
col += 1
|
||||||
this.tabBarAddBtnCol = col
|
this.tabBarAddBtnCol = col
|
||||||
col += 3
|
col += 3
|
||||||
|
|
||||||
@@ -682,16 +782,23 @@ export class DirectGridRenderer {
|
|||||||
|
|
||||||
let out = ""
|
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 btnSection: string
|
||||||
let btnVisibleLen: number
|
let btnVisibleLen: number
|
||||||
if (this.isExpanded) {
|
if (this.isExpanded) {
|
||||||
const selColor = this._selectMode ? `${hexFg("#9ece6a")}${BOLD}` : `${DIM}`
|
// Expanded: show close · minimize · select(green means select mode)
|
||||||
btnSection = `${RESET}${selColor}[SEL]${RESET}${borderColor}${hz}${RESET}${hexFg("#7dcfff")}[MIN]${RESET}${borderColor}`
|
const selDot = this._selectMode ? `${hexFg("#9ece6a")}${BOLD}●${RESET}` : DIM_DOT
|
||||||
btnVisibleLen = 5 + 1 + 5
|
btnSection = `${borderColor}${hz}${RESET}${RED_DOT} ${YELLOW_DOT} ${selDot}${borderColor}`
|
||||||
|
btnVisibleLen = 1 + 1 + 1 + 1 + 1 + 1 + 1 // ─● ● ●
|
||||||
} else {
|
} else {
|
||||||
btnSection = `${RESET}${DIM}[MAX]${RESET}${borderColor}`
|
// Grid: show close · expand
|
||||||
btnVisibleLen = 5
|
btnSection = `${borderColor}${hz}${RESET}${RED_DOT} ${GREEN_DOT}${borderColor}`
|
||||||
|
btnVisibleLen = 1 + 1 + 1 + 1 // ─● ●
|
||||||
}
|
}
|
||||||
const hzFill = Math.max(0, bw - 2 - btnVisibleLen - 1)
|
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}`
|
||||||
@@ -740,6 +847,17 @@ export class DirectGridRenderer {
|
|||||||
this.writeRaw(SYNC_START + frame + SYNC_END)
|
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 ─────────────────────────────────────────────
|
// ─── Input ─────────────────────────────────────────────
|
||||||
|
|
||||||
sendInputToFocused(rawSequence: string) {
|
sendInputToFocused(rawSequence: string) {
|
||||||
|
|||||||
119
src/data/session-store.ts
Normal file
119
src/data/session-store.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { app } from "./lib/state"
|
|||||||
import { updateAll, rebuildDisplayRows, updateUsagePanel, updateColumnHeaders } from "./ui/panels"
|
import { updateAll, rebuildDisplayRows, updateUsagePanel, updateColumnHeaders } from "./ui/panels"
|
||||||
import { stdinHandler } from "./input/handlers"
|
import { stdinHandler } from "./input/handlers"
|
||||||
import { resizeGridPanes } from "./grid/view-switch"
|
import { resizeGridPanes } from "./grid/view-switch"
|
||||||
|
import { loadSavedSession, extractSessionState, saveSessionSync } from "./data/session-store"
|
||||||
|
|
||||||
function refreshMockSessions(projects: Project[]) {
|
function refreshMockSessions(projects: Project[]) {
|
||||||
generateMockActiveSessions(projects)
|
generateMockActiveSessions(projects)
|
||||||
@@ -55,6 +56,9 @@ async function main() {
|
|||||||
app.sortedIndices = app.projects.map((_, i) => i)
|
app.sortedIndices = app.projects.map((_, i) => i)
|
||||||
rebuildDisplayRows()
|
rebuildDisplayRows()
|
||||||
|
|
||||||
|
// Load saved session for restore hint
|
||||||
|
app.savedSession = await loadSavedSession()
|
||||||
|
|
||||||
// Save raw stdout.write BEFORE OpenTUI intercepts it
|
// Save raw stdout.write BEFORE OpenTUI intercepts it
|
||||||
app.rawStdoutWrite = process.stdout.write.bind(process.stdout) as (s: string) => boolean
|
app.rawStdoutWrite = process.stdout.write.bind(process.stdout) as (s: string) => boolean
|
||||||
|
|
||||||
@@ -64,6 +68,11 @@ async function main() {
|
|||||||
useMouse: false,
|
useMouse: false,
|
||||||
onDestroy: () => {
|
onDestroy: () => {
|
||||||
app.destroyed = true
|
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.monitorInterval) { clearInterval(app.monitorInterval); app.monitorInterval = null }
|
||||||
if (app.directGrid) app.directGrid.destroyAll()
|
if (app.directGrid) app.directGrid.destroyAll()
|
||||||
stopAllCaptures()
|
stopAllCaptures()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { generateMockSessions, generateMockBranches } from "../data/mock"
|
|||||||
import { focusTerminalByPath, populateMockSessionStatus } from "../data/monitor"
|
import { focusTerminalByPath, populateMockSessionStatus } from "../data/monitor"
|
||||||
import { stopAllCaptures } from "../pty/capture"
|
import { stopAllCaptures } from "../pty/capture"
|
||||||
import type { DisplayRow } from "../lib/types"
|
import type { DisplayRow } from "../lib/types"
|
||||||
|
import { extractSessionState, saveSessionSync, restoreSession } from "../data/session-store"
|
||||||
|
|
||||||
// ─── Constants ───────────────────────────────────────────────────────
|
// ─── Constants ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -216,27 +217,62 @@ export function handlePickerClick(_col: number, screenRow: number) {
|
|||||||
function handlePickerTabBarClick(col: number, screenRow: number) {
|
function handlePickerTabBarClick(col: number, screenRow: number) {
|
||||||
// Tab bar is at row 1 in picker (rendered as OpenTUI text)
|
// Tab bar is at row 1 in picker (rendered as OpenTUI text)
|
||||||
if (screenRow !== 1) return false
|
if (screenRow !== 1) return false
|
||||||
// Hit test against tab bar positions (approximate, since OpenTUI renders it)
|
// Hit test against tab bar positions — Chrome-style layout
|
||||||
// We compute positions similar to the grid tab bar
|
// Picker: ╭ ● Picker ╮ = cols 1..10 (active) or ○ Picker = cols 1..10
|
||||||
let c = 2
|
let c = 1
|
||||||
// Picker tab
|
const pickerEnd = c + 10
|
||||||
const pickerEnd = c + 7
|
|
||||||
if (col >= c && col <= pickerEnd) return false // already on picker
|
if (col >= c && col <= pickerEnd) return false // already on picker
|
||||||
c += 11
|
c = 11
|
||||||
|
|
||||||
for (const tab of app.gridTabs) {
|
for (const tab of app.gridTabs) {
|
||||||
const count = app.directGrid?.getTabPaneCount(tab.id) ?? 0
|
const count = app.directGrid?.getTabPaneCount(tab.id) ?? 0
|
||||||
|
const isActive = app.viewMode === "grid" && app.directGrid?.activeTabId === tab.id
|
||||||
const label = `${tab.name} (${count})`
|
const label = `${tab.name} (${count})`
|
||||||
const visLen = 2 + label.length
|
const visLen = 2 + label.length // "● " + label
|
||||||
if (col >= c && col < c + visLen) {
|
|
||||||
switchToGridTab(tab.id)
|
const dg = app.directGrid
|
||||||
return true
|
|
||||||
|
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
|
// [+] button
|
||||||
if (col >= c && col <= c + 2) {
|
if (col >= c + 1 && col <= c + 3) {
|
||||||
createNewGridTab()
|
createNewGridTab()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -379,10 +415,48 @@ export async function handleKeypress(key: KeyEvent) {
|
|||||||
break
|
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":
|
case "escape":
|
||||||
|
if (app.restoreMode === "pending") {
|
||||||
|
app.restoreMode = null
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// fall through to quit
|
||||||
|
case "q":
|
||||||
app.destroyed = true
|
app.destroyed = true
|
||||||
if (app.monitorInterval) clearInterval(app.monitorInterval)
|
if (app.monitorInterval) clearInterval(app.monitorInterval)
|
||||||
|
// Save session before exit
|
||||||
|
try {
|
||||||
|
const state = extractSessionState()
|
||||||
|
if (state) saveSessionSync(state)
|
||||||
|
} catch {}
|
||||||
stopAllCaptures()
|
stopAllCaptures()
|
||||||
process.stdout.write("\x1b[?1006l")
|
process.stdout.write("\x1b[?1006l")
|
||||||
process.stdout.write("\x1b[?1000l")
|
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 === 65) { dg.sendScrollToFocused("down", 3); continue }
|
||||||
if (me.btn === 0 && !me.release) {
|
if (me.btn === 0 && !me.release) {
|
||||||
const btn = dg.checkButtonClick(me.col, me.row)
|
const btn = dg.checkButtonClick(me.col, me.row)
|
||||||
if (btn?.action === "max") dg.expandPane(btn.paneIndex)
|
if (btn?.action === "closetab" && btn.tabId !== undefined) {
|
||||||
else if (btn?.action === "min") dg.collapsePane()
|
const result = dg.requestCloseTab(btn.tabId)
|
||||||
else if (btn?.action === "sel") dg.enterSelectMode()
|
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") {
|
else if (btn?.action === "tab") {
|
||||||
|
dg.cancelPendingClose()
|
||||||
if (btn.tabId === -1) {
|
if (btn.tabId === -1) {
|
||||||
// Switch to picker
|
// Switch to picker
|
||||||
app.lastGridTabIndex = app.gridTabs.findIndex(t => t.id === dg.activeTabId)
|
app.lastGridTabIndex = app.gridTabs.findIndex(t => t.id === dg.activeTabId)
|
||||||
@@ -532,14 +645,16 @@ function processGridInput(str: string) {
|
|||||||
switchToGridTab(btn.tabId)
|
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) {
|
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
|
// Click on pane name in pane list → switch to that tab and focus the pane
|
||||||
switchToGridTab(btn.tabId)
|
switchToGridTab(btn.tabId)
|
||||||
dg.setFocus(btn.paneIndex)
|
dg.setFocus(btn.paneIndex)
|
||||||
if (app.clickExpand) dg.softExpandPane(btn.paneIndex)
|
if (app.clickExpand) dg.softExpandPane(btn.paneIndex)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
dg.cancelPendingClose()
|
||||||
// Pane body click
|
// Pane body click
|
||||||
if (app.clickExpand && !dg.isExpanded) {
|
if (app.clickExpand && !dg.isExpanded) {
|
||||||
const clickedIdx = dg.getPaneIndexAtClick(me.col, me.row)
|
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) {
|
let _pending = ""
|
||||||
const str = typeof data === "string" ? data : data.toString("utf8")
|
let _timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function dispatch(str: string) {
|
||||||
if (app.viewMode === "grid" && app.directGrid) processGridInput(str)
|
if (app.viewMode === "grid" && app.directGrid) processGridInput(str)
|
||||||
else processPickerInput(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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { CliRenderer } from "@opentui/core"
|
import type { CliRenderer } from "@opentui/core"
|
||||||
import type { BoxRenderable, TextRenderable, ScrollBoxRenderable } 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 { DirectGridRenderer } from "../components/direct-grid"
|
||||||
import type { UsageSummary } from "../data/usage"
|
import type { UsageSummary } from "../data/usage"
|
||||||
import type { IdleSessionInfo } from "../data/monitor"
|
import type { IdleSessionInfo } from "../data/monitor"
|
||||||
@@ -47,6 +47,8 @@ export const app = {
|
|||||||
nextTabId: 1, // auto-increment for tab ids
|
nextTabId: 1, // auto-increment for tab ids
|
||||||
clickExpand: true, // click-to-expand feature toggle
|
clickExpand: true, // click-to-expand feature toggle
|
||||||
lastGridTabIndex: 0, // last active grid tab for Ctrl+Space 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)
|
// UI refs (set during init)
|
||||||
renderer: null as unknown as CliRenderer,
|
renderer: null as unknown as CliRenderer,
|
||||||
|
|||||||
20
src/lib/styled.ts
Normal file
20
src/lib/styled.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -20,6 +20,15 @@ export function elapsedCompact(ms: number): string {
|
|||||||
return `${Math.floor(sec / 86400)}d`
|
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 {
|
export function formatSize(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes}B`
|
if (bytes < 1024) return `${bytes}B`
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||||
|
|||||||
@@ -46,3 +46,24 @@ export interface DisplayRow {
|
|||||||
sessionIndex?: number
|
sessionIndex?: number
|
||||||
branchName?: string
|
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[]
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,12 +9,14 @@ import {
|
|||||||
yellow,
|
yellow,
|
||||||
cyan,
|
cyan,
|
||||||
magenta,
|
magenta,
|
||||||
|
red,
|
||||||
} from "@opentui/core"
|
} from "@opentui/core"
|
||||||
|
import { st } from "../lib/styled"
|
||||||
import { app } from "../lib/state"
|
import { app } from "../lib/state"
|
||||||
import { CURSOR_BG, ACTIVE_BG, ACCENT } from "../lib/theme"
|
import { CURSOR_BG, ACTIVE_BG, ACCENT } from "../lib/theme"
|
||||||
import { getSessionStatus, getIdleSessions } from "../data/monitor"
|
import { getSessionStatus, getIdleSessions } from "../data/monitor"
|
||||||
import { formatCost, formatWindow, makeBar, pct, PLAN_LIMITS } from "../data/usage"
|
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"
|
import { fmtProjectRow, fmtSessionRow, fmtNewSessionRow, fmtBranchRow, fmtSyncIndicator } from "./formatters"
|
||||||
|
|
||||||
// ─── Display rows ────────────────────────────────────────────────────
|
// ─── Display rows ────────────────────────────────────────────────────
|
||||||
@@ -81,7 +83,7 @@ export function updatePaneList() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let content = t` `
|
const parts: Parameters<typeof st> = [t` `]
|
||||||
let first = true
|
let first = true
|
||||||
for (const tab of app.gridTabs) {
|
for (const tab of app.gridTabs) {
|
||||||
const tabPanes = app.directGrid.getTabPanes(tab.id)
|
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 short = name.length > 14 ? name.slice(0, 12) + "…" : name
|
||||||
const isFocused = app.directGrid!.activeTabId === tab.id && app.directGrid!.focusIndex === pi
|
const isFocused = app.directGrid!.activeTabId === tab.id && app.directGrid!.focusIndex === pi
|
||||||
|
|
||||||
if (!first) content = t`${content}${dim(" · ")}`
|
if (!first) parts.push(dim(" · "))
|
||||||
if (isFocused) {
|
parts.push(isFocused ? bold(short) : dim(short))
|
||||||
content = t`${content}${bold(short)}`
|
|
||||||
} else {
|
|
||||||
content = t`${content}${dim(short)}`
|
|
||||||
}
|
|
||||||
first = false
|
first = false
|
||||||
}
|
}
|
||||||
content = t`${content}${dim(" │ ")}`
|
parts.push(dim(" │ "))
|
||||||
first = true
|
first = true
|
||||||
}
|
}
|
||||||
app.paneListText.content = content
|
app.paneListText.content = st(...parts)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateTabBar() {
|
export function updateTabBar() {
|
||||||
if (!app.tabBarText) return
|
if (!app.tabBarText) return
|
||||||
|
|
||||||
// Build tab bar segments using styled text
|
|
||||||
const sep = dim(" │ ")
|
|
||||||
const pickerActive = app.viewMode === "picker"
|
const pickerActive = app.viewMode === "picker"
|
||||||
const pickerTab = pickerActive ? t`${cyan("●")} ${bold("Picker")}` : t`${dim("○ Picker")}`
|
const sep = dim(" │ ")
|
||||||
|
|
||||||
// Start with picker
|
// Chrome-style: active tab gets visual emphasis
|
||||||
let content = t` ${pickerTab}`
|
const parts: Parameters<typeof st> = []
|
||||||
|
if (pickerActive) {
|
||||||
|
parts.push(t` ${dim("╭")} ${cyan("●")} ${bold("Picker")} ${dim("╮")}`)
|
||||||
|
} else {
|
||||||
|
parts.push(t` ${dim("○ Picker")} `)
|
||||||
|
}
|
||||||
|
|
||||||
// Grid tabs
|
// Grid tabs
|
||||||
for (const tab of app.gridTabs) {
|
for (const tab of app.gridTabs) {
|
||||||
const count = app.directGrid?.getTabPaneCount(tab.id) ?? 0
|
const count = app.directGrid?.getTabPaneCount(tab.id) ?? 0
|
||||||
const hasIdle = app.directGrid?.hasIdleInTab(tab.id) ?? false
|
const hasIdle = app.directGrid?.hasIdleInTab(tab.id) ?? false
|
||||||
const isActive = app.viewMode === "grid" && app.directGrid?.activeTabId === tab.id
|
const isActive = app.viewMode === "grid" && app.directGrid?.activeTabId === tab.id
|
||||||
|
const isPending = app.directGrid?.pendingCloseTabId === tab.id
|
||||||
const label = `${tab.name} (${count})`
|
const label = `${tab.name} (${count})`
|
||||||
|
const closeBtn = isPending ? t` ${red(bold("●"))}` : t` ${dim("×")}`
|
||||||
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
content = t`${content}${sep}${cyan("●")} ${bold(label)}`
|
parts.push(dim("╭"), t` ${cyan("●")} ${bold(label)}`, closeBtn, t` ${dim("╮")}`)
|
||||||
} else if (hasIdle) {
|
} else if (hasIdle) {
|
||||||
content = t`${content}${sep}${yellow("◉")} ${label}`
|
parts.push(t` ${yellow("◉")} ${label}`, closeBtn, " ", sep)
|
||||||
} else {
|
} else {
|
||||||
content = t`${content}${sep}${dim("○ " + label)}`
|
parts.push(t` ${dim("○ " + label)}`, closeBtn, " ", sep)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
content = t`${content}${sep}${dim("[+]")}`
|
parts.push(t` ${dim("[+]")}`)
|
||||||
app.tabBarText.content = content
|
app.tabBarText.content = st(...parts)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Header / Footer ─────────────────────────────────────────────────
|
// ─── Header / Footer ─────────────────────────────────────────────────
|
||||||
@@ -167,13 +171,28 @@ export function updateColumnHeaders() {
|
|||||||
|
|
||||||
export function updateFooter() {
|
export function updateFooter() {
|
||||||
const gridHint = app.directGrid && app.directGrid.totalPaneCount > 0 ? " │ ^space grid" : ""
|
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) {
|
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 + restoreHint
|
||||||
)}`
|
)}`
|
||||||
} else {
|
} else {
|
||||||
app.footerText.content = t` ${dim(
|
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
|
const selNote = selBranch === br.name
|
||||||
? t` ${green("Selected")} — will launch with: ${dim(`-p "switch to branch ${br.name}, stash if needed"`)}`
|
? 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")}`
|
: 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}
|
${dim("Last commit:")} ${br.lastCommitAge} — ${br.lastCommitMsg}
|
||||||
${selNote}`
|
`, selNote)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
app.previewText.content = t` ${green("Start a new Claude session")} in ${bold(project.name)}
|
app.previewText.content = t` ${green("Start a new Claude session")} in ${bold(project.name)}
|
||||||
|
|||||||
Reference in New Issue
Block a user