Merge branch 'ft-tmux' — direct PTY grid, inline tabs, select mode

Replaces tmux dependency with direct PTY grid rendering:
- Embedded terminal panes with weighted grid layout
- Tabbed sessions with inline pane names and status icons
- Double-click select mode with full scrollback buffer
- Alt+key passthrough, click-to-expand, add-pane mode
This commit is contained in:
Alejandro Gutiérrez
2026-02-28 18:13:42 +00:00
26 changed files with 4763 additions and 1087 deletions

View File

@@ -6,6 +6,7 @@
"name": "tui-claude-director", "name": "tui-claude-director",
"dependencies": { "dependencies": {
"@opentui/core": "^0.1.81", "@opentui/core": "^0.1.81",
"node-pty": "^1.1.0",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
@@ -146,6 +147,10 @@
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
"node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="],
"omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],

View File

@@ -29,6 +29,7 @@
"typescript": "^5" "typescript": "^5"
}, },
"dependencies": { "dependencies": {
"@opentui/core": "^0.1.81" "@opentui/core": "^0.1.81",
"node-pty": "^1.1.0"
} }
} }

165
src/actions/launch.ts Normal file
View File

@@ -0,0 +1,165 @@
import { app } from "../lib/state"
import { updateAll, rebuildDisplayRows } from "../ui/panels"
import { ensureGridView, createNewGridTab, switchToGridTab } from "../grid/view-switch"
import { loadSessions } from "../data/sessions"
import { createSession } from "../pty/session-manager"
export async function doAddPane() {
const targetTabId = app.addPaneTargetTabId
if (!targetTabId || !app.directGrid) return
if (app.selectedProjects.size === 0 && app.selectedSessions.size === 0) return
type LaunchItem = { path: string; name: string; sessionId?: string; targetBranch?: string }
const items: LaunchItem[] = []
for (const [path] of app.selectedProjects) {
const project = app.projects.find(p => p.path === path)
if (!project) continue
const targetBranch = app.selectedBranches.get(path)
const needsBranch = targetBranch && targetBranch !== project.branch
if (!project.sessions) {
project.sessions = await loadSessions(project.path)
project.sessionCount = project.sessions.length
}
const lastSessionId = project.sessions[0]?.id
items.push({ path, name: project.name, sessionId: lastSessionId, targetBranch: needsBranch ? targetBranch : undefined })
}
for (const project of app.projects) {
if (!project.sessions) continue
for (const session of project.sessions) {
if (app.selectedSessions.has(session.id)) {
const targetBranch = app.selectedBranches.get(project.path)
const needsBranch = targetBranch && targetBranch !== project.branch
items.push({ path: project.path, name: project.name, sessionId: session.id, targetBranch: needsBranch ? targetBranch : undefined })
}
}
}
if (items.length === 0) return
const termW = process.stdout.columns || 120
const termH = process.stdout.rows || 40
const totalPanes = items.length + (app.directGrid.getTabPaneCount(targetTabId) || 0)
const cols = totalPanes <= 1 ? 1 : totalPanes <= 2 ? 2 : totalPanes <= 4 ? 2 : 3
const rows = Math.ceil(totalPanes / cols)
const paneW = Math.max(Math.floor(termW / cols) - 2, 20)
const paneH = Math.max(Math.floor((termH - 2) / rows) - 4, 6)
for (const item of items) {
const session = await createSession({
projectPath: item.path,
projectName: item.name,
sessionId: item.sessionId,
targetBranch: item.targetBranch,
width: paneW,
height: paneH,
})
await app.directGrid.addPane(session, targetTabId)
}
app.selectedProjects.clear()
app.selectedSessions.clear()
app.selectedBranches.clear()
app.addPaneTargetTabId = null
switchToGridTab(targetTabId)
}
export async function doLaunch() {
if (app.selectedProjects.size === 0 && app.selectedSessions.size === 0) return
if (app.demoMode) {
app.selectedProjects.clear()
app.selectedSessions.clear()
app.selectedBranches.clear()
rebuildDisplayRows()
updateAll()
return
}
type LaunchItem = { path: string; name: string; tabNum: number; sessionId?: string; targetBranch?: string }
const items: LaunchItem[] = []
for (const [path, tabNum] of app.selectedProjects) {
const project = app.projects.find(p => p.path === path)
if (!project) continue
const targetBranch = app.selectedBranches.get(path)
const needsBranch = targetBranch && targetBranch !== project.branch
if (!project.sessions) {
project.sessions = await loadSessions(project.path)
project.sessionCount = project.sessions.length
}
const lastSessionId = project.sessions[0]?.id
items.push({ path, name: project.name, tabNum, sessionId: lastSessionId, targetBranch: needsBranch ? targetBranch : undefined })
}
for (const project of app.projects) {
if (!project.sessions) continue
for (const session of project.sessions) {
if (app.selectedSessions.has(session.id)) {
const targetBranch = app.selectedBranches.get(project.path)
const needsBranch = targetBranch && targetBranch !== project.branch
// Sessions without explicit tab number go to tab 1
items.push({ path: project.path, name: project.name, tabNum: 1, sessionId: session.id, targetBranch: needsBranch ? targetBranch : undefined })
}
}
}
if (items.length === 0) return
// Group items by tab number
const byTab = new Map<number, LaunchItem[]>()
for (const item of items) {
if (!byTab.has(item.tabNum)) byTab.set(item.tabNum, [])
byTab.get(item.tabNum)!.push(item)
}
ensureGridView()
// Launch each tab group into its own grid tab
for (const [tabNum, tabItems] of byTab) {
// Find existing grid tab for this number, or create one
let targetTabId: number
const existingTab = app.gridTabs.find(t => t.name === `Tab ${tabNum}`)
if (existingTab) {
targetTabId = existingTab.id
} else {
targetTabId = createNewGridTab()
// Rename to match the picker tab number
const tab = app.gridTabs.find(t => t.id === targetTabId)
if (tab) {
tab.name = `Tab ${tabNum}`
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
})
}
}
const termW = process.stdout.columns || 120
const termH = process.stdout.rows || 40
const totalPanes = tabItems.length + (app.directGrid?.getTabPaneCount(targetTabId) || 0)
const cols = totalPanes <= 1 ? 1 : totalPanes <= 2 ? 2 : totalPanes <= 4 ? 2 : 3
const rows = Math.ceil(totalPanes / cols)
const paneW = Math.max(Math.floor(termW / cols) - 2, 20)
const paneH = Math.max(Math.floor((termH - 2) / rows) - 4, 6)
for (const item of tabItems) {
const session = await createSession({
projectPath: item.path,
projectName: item.name,
sessionId: item.sessionId,
targetBranch: item.targetBranch,
width: paneW,
height: paneH,
})
await app.directGrid!.addPane(session, targetTabId)
}
switchToGridTab(targetTabId)
}
app.selectedProjects.clear()
app.selectedSessions.clear()
app.selectedBranches.clear()
}

View File

@@ -1,4 +1,5 @@
import type { Project } from "../lib/types" import type { Project } from "../lib/types"
import { loadSessions } from "../data/sessions"
interface LaunchItem { interface LaunchItem {
path: string path: string
@@ -8,18 +9,27 @@ interface LaunchItem {
export async function launchSelections( export async function launchSelections(
projects: Project[], projects: Project[],
selectedProjects: Set<string>, selectedProjects: Map<string, number>,
selectedSessions: Set<string>, selectedSessions: Set<string>,
selectedBranches: Map<string, string> = new Map() selectedBranches: Map<string, string> = new Map()
): Promise<number> { ): Promise<number> {
const byProject = new Map<string, LaunchItem[]>() const byProject = new Map<string, LaunchItem[]>()
for (const path of selectedProjects) { for (const [path] of selectedProjects) {
if (!byProject.has(path)) byProject.set(path, []) if (!byProject.has(path)) byProject.set(path, [])
const targetBranch = selectedBranches.get(path) const targetBranch = selectedBranches.get(path)
const project = projects.find(p => p.path === path) const project = projects.find(p => p.path === path)
const needsBranch = targetBranch && project && targetBranch !== project.branch const needsBranch = targetBranch && project && targetBranch !== project.branch
byProject.get(path)!.push({ path, targetBranch: needsBranch ? targetBranch : undefined }) // Auto-resume most recent session
let lastSessionId: string | undefined
if (project) {
if (!project.sessions) {
project.sessions = await loadSessions(project.path)
project.sessionCount = project.sessions.length
}
lastSessionId = project.sessions[0]?.id
}
byProject.get(path)!.push({ path, sessionId: lastSessionId, targetBranch: needsBranch ? targetBranch : undefined })
} }
for (const project of projects) { for (const project of projects) {
@@ -40,7 +50,7 @@ export async function launchSelections(
let count = 0 let count = 0
for (const [, items] of byProject) { for (const [, items] of byProject) {
const first = items[0] const first = items[0]!
const firstCmd = buildCmd(first) const firstCmd = buildCmd(first)
const newWindowScript = [ const newWindowScript = [
@@ -55,7 +65,7 @@ export async function launchSelections(
for (let i = 1; i < items.length; i++) { for (let i = 1; i < items.length; i++) {
await Bun.sleep(400) await Bun.sleep(400)
const cmd = buildCmd(items[i]) const cmd = buildCmd(items[i]!)
await runOsascript( await runOsascript(
'tell application "System Events" to keystroke "t" using command down' 'tell application "System Events" to keystroke "t" using command down'

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
// Lightweight terminal pane: writes raw ANSI from PTY capture directly to stdout.
// No parsing, no FrameBuffer, no OpenTUI. Just cursor-addressed raw lines.
import { onFrame, hasChanged, resetHash, type CaptureResult } from "../pty/capture"
export class DirectPane {
screenX: number // 1-based screen column of content area
screenY: number // 1-based screen row of content area
width: number // content columns
height: number // content rows
sessionName = ""
private unsub: (() => void) | null = null
// Set by DirectGridRenderer to receive frame updates
onFrame: ((lines: string[], pane: DirectPane) => void) | null = null
constructor(x: number, y: number, w: number, h: number) {
this.screenX = x
this.screenY = y
this.width = w
this.height = h
}
attach(sessionName: string) {
this.detach()
this.sessionName = sessionName
resetHash(`dp_${sessionName}`)
this.unsub = onFrame(sessionName, (result) => {
if (!hasChanged(result.lines, `dp_${sessionName}`)) return
if (this.onFrame) this.onFrame(result.lines, this)
})
}
detach() {
if (this.unsub) { this.unsub(); this.unsub = null }
if (this.sessionName) resetHash(`dp_${this.sessionName}`)
this.sessionName = ""
this.onFrame = null
}
reposition(x: number, y: number, w: number, h: number) {
this.screenX = x
this.screenY = y
this.width = w
this.height = h
}
// Build cursor-addressed ANSI output for this pane's content.
// Returns a string ready for stdout.write(). No allocations beyond the string.
buildFrame(lines: string[]): string {
let out = ""
const x = this.screenX
const y = this.screenY
const w = this.width
const h = this.height
for (let row = 0; row < h; row++) {
// Position cursor at start of this row
out += `\x1b[${y + row};${x}H`
// Erase w characters (clears old content)
out += `\x1b[${w}X`
if (row < lines.length) {
// Write raw ANSI line from PTY (already correct width)
out += lines[row]
// Reset SGR to prevent color bleed into border
out += "\x1b[0m"
}
}
return out
}
}

View File

@@ -10,13 +10,31 @@ import {
} from "@opentui/core" } from "@opentui/core"
import { TerminalView, getProjectColor } from "./terminal-view" import { TerminalView, getProjectColor } from "./terminal-view"
import type { TmuxSession } from "../tmux/session-manager" import type { TmuxSession } from "../tmux/session-manager"
import { sendKeys } from "../tmux/input-bridge" import { sendKeys, sendMouseEvent } from "../tmux/input-bridge"
export type PaneStatus = "busy" | "idle" | null
export interface GridPane { export interface GridPane {
session: TmuxSession session: TmuxSession
termView: TerminalView termView: TerminalView
borderBox: BoxRenderable borderBox: BoxRenderable
titleText: TextRenderable titleText: TextRenderable
subtitleText: TextRenderable
status: PaneStatus
statusSince: number // Date.now() when status was set
}
function fmtElapsed(sinceMs: number): string {
if (!sinceMs) return ""
const sec = Math.floor((Date.now() - sinceMs) / 1000)
if (sec < 1) return "0s"
if (sec < 60) return `${sec}s`
const m = Math.floor(sec / 60)
const s = sec % 60
if (m < 60) return `${m}m${s > 0 ? String(s).padStart(2, "0") + "s" : ""}`
const h = Math.floor(m / 60)
const rm = m % 60
return `${h}h${rm > 0 ? String(rm).padStart(2, "0") + "m" : ""}`
} }
export class SessionGrid { export class SessionGrid {
@@ -25,17 +43,19 @@ export class SessionGrid {
private panes: GridPane[] = [] private panes: GridPane[] = []
private _focusIndex = 0 private _focusIndex = 0
private flashTimers = new Map<string, ReturnType<typeof setInterval>>() private flashTimers = new Map<string, ReturnType<typeof setInterval>>()
private titleTimer: ReturnType<typeof setInterval> | null = null
constructor(renderer: CliRenderer, container: BoxRenderable) { constructor(renderer: CliRenderer, container: BoxRenderable) {
this.renderer = renderer this.renderer = renderer
this.container = container this.container = container
this.titleTimer = setInterval(() => this.refreshTitles(), 1000)
} }
get focusIndex() { return this._focusIndex } get focusIndex() { return this._focusIndex }
get paneCount() { return this.panes.length } get paneCount() { return this.panes.length }
get focusedPane(): GridPane | null { return this.panes[this._focusIndex] ?? null } get focusedPane(): GridPane | null { return this.panes[this._focusIndex] ?? null }
addSession(session: TmuxSession): GridPane { addSession(session: TmuxSession, subtitle?: ReturnType<typeof t>): GridPane {
const color = getProjectColor(session.colorIndex) const color = getProjectColor(session.colorIndex)
const colorRGBA = RGBA.fromHex(color) const colorRGBA = RGBA.fromHex(color)
@@ -55,18 +75,27 @@ export class SessionGrid {
}) })
titleText.content = t` ${bold(fg(color)(session.projectName))}${session.sessionId ? dim(` #${session.sessionId.slice(0, 8)}`) : ""}` titleText.content = t` ${bold(fg(color)(session.projectName))}${session.sessionId ? dim(` #${session.sessionId.slice(0, 8)}`) : ""}`
// Calculate pane size (leave room for border + title) const subtitleText = new TextRenderable(this.renderer, {
width: "100%",
height: 1,
flexShrink: 0,
})
if (subtitle) subtitleText.content = subtitle
else subtitleText.content = t` ${dim("...")}`
// Calculate pane size (leave room for border + title + subtitle)
const dims = this.calcPaneDims() const dims = this.calcPaneDims()
const termView = new TerminalView(this.renderer, { const termView = new TerminalView(this.renderer, {
width: Math.max(dims.w - 2, 10), width: Math.max(dims.w - 2, 10),
height: Math.max(dims.h - 3, 4), height: Math.max(dims.h - 4, 4),
}) })
borderBox.add(titleText) borderBox.add(titleText)
borderBox.add(subtitleText)
borderBox.add(termView) borderBox.add(termView)
this.container.add(borderBox) this.container.add(borderBox)
const pane: GridPane = { session, termView, borderBox, titleText } const pane: GridPane = { session, termView, borderBox, titleText, subtitleText, status: null, statusSince: 0 }
this.panes.push(pane) this.panes.push(pane)
termView.attach(session) termView.attach(session)
@@ -114,10 +143,110 @@ export class SessionGrid {
} }
} }
async sendInputToFocused(rawSequence: string) { flashFocused() {
const pane = this.focusedPane const pane = this.focusedPane
if (!pane) return if (!pane) return
await sendKeys(pane.session.name, rawSequence) const flashColor = RGBA.fromHex("#7dcfff")
pane.borderBox.borderColor = flashColor
this.renderer.requestRender()
setTimeout(() => {
// Restore to heavy white (focused state)
pane.borderBox.borderColor = RGBA.fromHex("#ffffff")
this.renderer.requestRender()
}, 150)
}
focusByDirection(dir: "up" | "down" | "left" | "right") {
const n = this.panes.length
if (n <= 1) return
const cols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4
const rows = Math.ceil(n / cols)
const curCol = this._focusIndex % cols
const curRow = Math.floor(this._focusIndex / cols)
let newCol = curCol
let newRow = curRow
switch (dir) {
case "left": newCol = (curCol - 1 + cols) % cols; break
case "right": newCol = (curCol + 1) % cols; break
case "up": newRow = (curRow - 1 + rows) % rows; break
case "down": newRow = (curRow + 1) % rows; break
}
const idx = newRow * cols + newCol
if (idx >= 0 && idx < n) {
this._focusIndex = idx
this.updateBorders()
}
}
focusByClick(col: number, row: number): boolean {
const n = this.panes.length
if (n === 0) return false
const termW = process.stdout.columns || 120
const termH = process.stdout.rows || 40
const cols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4
const rows = Math.ceil(n / cols)
const cellW = Math.floor(termW / cols)
const cellH = Math.floor((termH - 2) / rows) // -2 for header+footer
const gridCol = Math.floor(col / cellW)
const gridRow = Math.floor((row - 1) / cellH) // -1 for header line
const idx = gridRow * cols + gridCol
if (idx >= 0 && idx < n) {
this._focusIndex = idx
this.updateBorders()
return true
}
return false
}
sendInputToFocused(rawSequence: string) {
const pane = this.focusedPane
if (!pane) return
sendKeys(pane.session.name, rawSequence)
pane.termView.nudge()
}
// Hit-test: map absolute screen coords to pane index + relative terminal coords
hitTest(absCol: number, absRow: number): { index: number, relX: number, relY: number } | null {
const n = this.panes.length
if (n === 0) return null
const termW = process.stdout.columns || 120
const termH = process.stdout.rows || 40
const gridCols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4
const gridRows = Math.ceil(n / gridCols)
const cellW = Math.floor(termW / gridCols)
const cellH = Math.floor((termH - 2) / gridRows)
// Convert 1-based screen coords to grid cell
const gc = Math.floor((absCol - 1) / cellW)
const gr = Math.floor((absRow - 2) / cellH) // row 2 is first grid row (row 1 = header)
if (gc < 0 || gc >= gridCols || gr < 0 || gr >= gridRows) return null
const idx = gr * gridCols + gc
if (idx < 0 || idx >= n) return null
// Terminal content within cell: after left border(1) + after top border(1)+title(1)+subtitle(1)
const termStartX = gc * cellW + 2 // 1-based + left border
const termStartY = 2 + gr * cellH + 3 // header(1) + top border(1) + title(1) + subtitle(1)
return {
index: idx,
relX: absCol - termStartX + 1, // 1-based for tmux
relY: absRow - termStartY + 1,
}
}
// Forward mouse event to the focused tmux pane with correct relative coordinates
sendMouseToFocused(absCol: number, absRow: number, btn: number, release: boolean) {
const pane = this.focusedPane
if (!pane) return
const hit = this.hitTest(absCol, absRow)
if (!hit || hit.index !== this._focusIndex) return
if (hit.relX < 1 || hit.relY < 1) return
if (hit.relX > pane.session.width || hit.relY > pane.session.height) return
sendMouseEvent(pane.session.name, hit.relX, hit.relY, btn, release)
} }
// Flash a pane's border to draw attention (e.g., when session goes idle) // Flash a pane's border to draw attention (e.g., when session goes idle)
@@ -159,34 +288,69 @@ export class SessionGrid {
markIdle(sessionName: string) { markIdle(sessionName: string) {
const pane = this.panes.find(p => p.session.name === sessionName) const pane = this.panes.find(p => p.session.name === sessionName)
if (!pane) return if (!pane) return
if (pane.status !== "idle") {
pane.status = "idle"
pane.statusSince = Date.now()
}
this.startFlash(sessionName) this.startFlash(sessionName)
pane.titleText.content = t` ${bold(fg(getProjectColor(pane.session.colorIndex))(pane.session.projectName))} ${fg("#e0af68")("NEEDS INPUT")}` this.renderPaneTitle(pane)
this.renderer.requestRender()
} }
markBusy(sessionName: string) { markBusy(sessionName: string) {
const pane = this.panes.find(p => p.session.name === sessionName) const pane = this.panes.find(p => p.session.name === sessionName)
if (!pane) return if (!pane) return
if (pane.status !== "busy") {
pane.status = "busy"
pane.statusSince = Date.now()
}
this.clearFlash(sessionName) this.clearFlash(sessionName)
pane.titleText.content = t` ${bold(fg(getProjectColor(pane.session.colorIndex))(pane.session.projectName))} ${fg("#9ece6a")("RUNNING")}` this.renderPaneTitle(pane)
this.renderer.requestRender()
} }
clearMark(sessionName: string) { clearMark(sessionName: string) {
const pane = this.panes.find(p => p.session.name === sessionName) const pane = this.panes.find(p => p.session.name === sessionName)
if (!pane) return if (!pane) return
pane.status = null
pane.statusSince = 0
this.clearFlash(sessionName) this.clearFlash(sessionName)
this.renderPaneTitle(pane)
}
private renderPaneTitle(pane: GridPane) {
const color = getProjectColor(pane.session.colorIndex) const color = getProjectColor(pane.session.colorIndex)
pane.titleText.content = t` ${bold(fg(color)(pane.session.projectName))}${pane.session.sessionId ? dim(` #${pane.session.sessionId.slice(0, 8)}`) : ""}` const name = bold(fg(color)(pane.session.projectName))
const elapsed = pane.statusSince ? fmtElapsed(pane.statusSince) : ""
if (pane.status === "idle") {
pane.titleText.content = t` ${name} ${fg("#e0af68")("IDLE")} ${dim(elapsed)}`
} else if (pane.status === "busy") {
pane.titleText.content = t` ${name} ${fg("#9ece6a")("RUNNING")} ${dim(elapsed)}`
} else {
pane.titleText.content = t` ${name}${pane.session.sessionId ? dim(` #${pane.session.sessionId.slice(0, 8)}`) : ""}`
}
this.renderer.requestRender() this.renderer.requestRender()
} }
private refreshTitles() {
let needsRender = false
for (const pane of this.panes) {
if (pane.status && pane.statusSince) {
this.renderPaneTitle(pane)
needsRender = true
}
}
if (needsRender) this.renderer.requestRender()
}
private updateBorders() { private updateBorders() {
for (let i = 0; i < this.panes.length; i++) { for (let i = 0; i < this.panes.length; i++) {
const pane = this.panes[i] const pane = this.panes[i]
const isFocused = i === this._focusIndex const isFocused = i === this._focusIndex
const color = getProjectColor(pane.session.colorIndex) const color = getProjectColor(pane.session.colorIndex)
// Update terminal view focus state (controls poll rate)
pane.termView.focused = isFocused
// Focused pane gets brighter border, others get dimmer // Focused pane gets brighter border, others get dimmer
if (isFocused) { if (isFocused) {
pane.borderBox.borderColor = RGBA.fromHex("#ffffff") pane.borderBox.borderColor = RGBA.fromHex("#ffffff")
@@ -241,6 +405,7 @@ export class SessionGrid {
} }
destroyAll() { destroyAll() {
if (this.titleTimer) { clearInterval(this.titleTimer); this.titleTimer = null }
for (const timer of this.flashTimers.values()) clearInterval(timer) for (const timer of this.flashTimers.values()) clearInterval(timer)
this.flashTimers.clear() this.flashTimers.clear()
for (const pane of this.panes) { for (const pane of this.panes) {

View File

@@ -6,7 +6,7 @@ import {
RGBA, RGBA,
TextAttributes, TextAttributes,
} from "@opentui/core" } from "@opentui/core"
import { capturePane, hasChanged, resetHash } from "../tmux/capture" import { startCapture, stopCapture, setCaptureRate, onFrame, hasChanged, resetHash, type CaptureResult } from "../tmux/capture"
import { parseAnsiFrame, type ParsedFrame } from "../tmux/ansi-parser" import { parseAnsiFrame, type ParsedFrame } from "../tmux/ansi-parser"
import type { TmuxSession } from "../tmux/session-manager" import type { TmuxSession } from "../tmux/session-manager"
@@ -28,34 +28,52 @@ export function getProjectColor(colorIndex: number): string {
return PROJECT_COLORS[colorIndex % PROJECT_COLORS.length] return PROJECT_COLORS[colorIndex % PROJECT_COLORS.length]
} }
// Push-based terminal view: no poll timers.
// Subscribes to capture stream and renders only when content changes.
export class TerminalView extends FrameBufferRenderable { export class TerminalView extends FrameBufferRenderable {
session: TmuxSession | null = null session: TmuxSession | null = null
private pollTimer: ReturnType<typeof setInterval> | null = null private unsubCapture: (() => void) | null = null
private lastFrame: ParsedFrame | null = null private lastFrame: ParsedFrame | null = null
private _focused = false private _focused = false
private _flashUntil = 0 // timestamp until which border flashes private _flashUntil = 0
private _idleSince = 0 private _idleSince = 0
private _frameDirty = false
constructor(ctx: RenderContext, options: FrameBufferOptions) { constructor(ctx: RenderContext, options: FrameBufferOptions) {
super(ctx, options) super(ctx, options)
} }
get focused() { return this._focused } get focused() { return this._focused }
set focused(v: boolean) { this._focused = v } set focused(v: boolean) {
if (this._focused === v) return
this._focused = v
// Focused pane captures at ~60fps, unfocused at ~5fps
if (this.session) {
setCaptureRate(this.session.name, v ? 16 : 200)
}
}
get idleSince() { return this._idleSince } get idleSince() { return this._idleSince }
attach(session: TmuxSession) { attach(session: TmuxSession) {
this.detach() this.detach()
this.session = session this.session = session
resetHash() resetHash(session.name)
this.startPolling() const captureMs = this._focused ? 16 : 200
startCapture(session.name, captureMs)
// Subscribe to push notifications — no poll timer needed
this.unsubCapture = onFrame(session.name, (frame) => this.onNewFrame(frame))
} }
detach() { detach() {
this.stopPolling() if (this.unsubCapture) { this.unsubCapture(); this.unsubCapture = null }
if (this.session) {
stopCapture(this.session.name)
resetHash(this.session.name)
}
this.session = null this.session = null
this.lastFrame = null this.lastFrame = null
this._frameDirty = false
} }
flash(durationMs = 2000) { flash(durationMs = 2000) {
@@ -70,29 +88,19 @@ export class TerminalView extends FrameBufferRenderable {
this._idleSince = 0 this._idleSince = 0
} }
private startPolling() { // Hint that input was sent — request immediate render
if (this.pollTimer) return nudge() {
this.pollTimer = setInterval(() => this.refresh(), 80) this.requestRender()
} }
private stopPolling() { private onNewFrame(result: CaptureResult) {
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
}
private async refresh() {
if (!this.session) return if (!this.session) return
if (!hasChanged(result.lines, this.session.name)) return
const result = await capturePane(this.session.name)
if (!result) return
if (!hasChanged(result.lines)) return
const frame = parseAnsiFrame(result.lines, result.width, result.height) const frame = parseAnsiFrame(result.lines, result.width, result.height)
this.lastFrame = frame this.lastFrame = frame
this.renderFrameToBuffer(frame) this._frameDirty = true
this.requestRender()
} }
private renderFrameToBuffer(frame: ParsedFrame) { private renderFrameToBuffer(frame: ParsedFrame) {
@@ -113,17 +121,20 @@ export class TerminalView extends FrameBufferRenderable {
} }
} }
// Only write to framebuffer when content actually changed (dirty flag)
// Previously this re-rendered EVERY paint cycle — major CPU waste
protected renderSelf(buffer: OptimizedBuffer) { protected renderSelf(buffer: OptimizedBuffer) {
if (this.lastFrame) { if (this._frameDirty && this.lastFrame) {
this.renderFrameToBuffer(this.lastFrame) this.renderFrameToBuffer(this.lastFrame)
this._frameDirty = false
} }
super.renderSelf(buffer) super.renderSelf(buffer)
} }
protected onResize(width: number, height: number) { protected onResize(width: number, height: number) {
super.onResize(width, height) super.onResize(width, height)
this._frameDirty = true // Re-render frame to new buffer size
if (this.session) { if (this.session) {
// Resize tmux pane to match (async, fire-and-forget)
import("../tmux/session-manager").then(m => { import("../tmux/session-manager").then(m => {
if (this.session) m.resizePane(this.session.name, width, height) if (this.session) m.resizePane(this.session.name, width, height)
}) })

View File

@@ -312,15 +312,40 @@ export function updateProjectSessions(projects: Project[], sessions: Map<string,
return changed return changed
} }
const IDLE_SOUND_DELAY_MS = 10_000
const pendingIdle = new Map<string, number>() // path → timestamp when first went idle
const notifiedIdle = new Set<string>() // paths already notified — prevents re-trigger
export function checkTransitions( export function checkTransitions(
projects: Project[], projects: Project[],
prevBusy: Map<string, number> prevBusy: Map<string, number>
): string[] { ): string[] {
const now = Date.now()
const transitioned: string[] = [] const transitioned: string[] = []
for (const project of projects) { for (const project of projects) {
const prev = prevBusy.get(project.path) || 0 const prev = prevBusy.get(project.path) || 0
if (prev > 0 && project.busySessions === 0 && project.activeSessions > 0) { const isIdle = project.busySessions === 0 && project.activeSessions > 0
if (!isIdle) {
// Not idle — clear notification state so next idle transition can fire
notifiedIdle.delete(project.path)
pendingIdle.delete(project.path)
continue
}
// Already notified for this idle period — skip
if (notifiedIdle.has(project.path)) continue
if (prev > 0 && !pendingIdle.has(project.path)) {
// Just transitioned busy→idle — start the delay timer
pendingIdle.set(project.path, now)
}
if (pendingIdle.has(project.path) && now - pendingIdle.get(project.path)! >= IDLE_SOUND_DELAY_MS) {
// Confirmed idle for 10+ seconds — notify once
transitioned.push(project.name) transitioned.push(project.name)
pendingIdle.delete(project.path)
notifiedIdle.add(project.path)
} }
} }
return transitioned return transitioned

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
}

71
src/grid/view-switch.ts Normal file
View File

@@ -0,0 +1,71 @@
import { app } from "../lib/state"
import { DirectGridRenderer } from "../components/direct-grid"
export function ensureGridView() {
if (app.viewMode === "grid" && app.directGrid) return
switchToGrid()
}
export function switchToGrid() {
app.viewMode = "grid"
const isNew = !app.directGrid
if (isNew) {
app.directGrid = new DirectGridRenderer(app.rawStdoutWrite)
}
app.renderer.suspend()
if (process.stdin.isTTY) process.stdin.setRawMode(true)
process.stdin.resume()
app.rawStdoutWrite("\x1b[?1049h")
app.rawStdoutWrite("\x1b[?1000h")
app.rawStdoutWrite("\x1b[?1006h")
if (isNew || app.directGrid!.totalPaneCount === 0) {
app.directGrid!.start()
} else {
app.directGrid!.resume()
}
}
export function switchToGridTab(tabId: number) {
const tab = app.gridTabs.find(t => t.id === tabId)
if (!tab) return
// Track last grid tab for Ctrl+Space toggle
app.lastGridTabIndex = app.gridTabs.indexOf(tab)
if (app.viewMode !== "grid") {
switchToGrid()
}
app.activeTabIndex = app.gridTabs.indexOf(tab) + 1
app.directGrid!.setActiveTab(tabId)
}
export function createNewGridTab(): number {
const tabId = app.nextTabId++
const tab = { id: tabId, name: `Tab ${tabId}` }
app.gridTabs.push(tab)
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
})
if (!app.directGrid) {
app.directGrid = new DirectGridRenderer(app.rawStdoutWrite)
}
app.directGrid.addTab(tab)
// Switch to the new tab
switchToGridTab(tabId)
return tabId
}
export function resizeGridPanes() {
if (!app.directGrid || app.directGrid.paneCount === 0) return
app.directGrid.repositionAll()
}

File diff suppressed because it is too large Load Diff

900
src/input/handlers.ts Normal file
View File

@@ -0,0 +1,900 @@
import type { KeyEvent } from "@opentui/core"
import { app } from "../lib/state"
import { updateAll, rebuildDisplayRows, applySortMode, updateTabBar } from "../ui/panels"
import { extractKeyboardInput, extractMouseEvents } from "./parser"
import { switchToGrid, switchToGridTab, createNewGridTab } from "../grid/view-switch"
import { doLaunch, doAddPane } from "../actions/launch"
import { launchSelections } from "../actions/launcher"
import { loadSessions } from "../data/sessions"
import { loadBranches } from "../data/git"
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 ───────────────────────────────────────────────────────
const SHIFT_ARROWS: Record<string, "up" | "down" | "left" | "right"> = {
"\x1b[1;2A": "up",
"\x1b[1;2B": "down",
"\x1b[1;2C": "right",
"\x1b[1;2D": "left",
"\x1b[a": "up",
"\x1b[b": "down",
"\x1b[c": "right",
"\x1b[d": "left",
}
const KEY_MAP: Record<string, { name: string; shift?: boolean; ctrl?: boolean }> = {
"\x1b[A": { name: "up" },
"\x1b[B": { name: "down" },
"\x1b[C": { name: "right" },
"\x1b[D": { name: "left" },
"\x1b[5~": { name: "pageup" },
"\x1b[6~": { name: "pagedown" },
"\x1b[H": { name: "home" },
"\x1b[F": { name: "end" },
"\x1bOH": { name: "home" },
"\x1bOF": { name: "end" },
"\x1b[Z": { name: "tab", shift: true },
"\x1b[1;2A": { name: "up", shift: true },
"\x1b[1;2B": { name: "down", shift: true },
"\x1b[1;2C": { name: "right", shift: true },
"\x1b[1;2D": { name: "left", shift: true },
"\x09": { name: "tab" },
"\x0d": { name: "return" },
"\x1b": { name: "escape" },
" ": { name: "space" },
}
const NOOP = () => {}
// ─── Selection helpers ───────────────────────────────────────────────
function toggleSetItem<T>(set: Set<T>, item: T) {
if (set.has(item)) set.delete(item)
else set.add(item)
}
const MAX_TAB_NUM = 9
function toggleRowSelection(row: DisplayRow) {
const project = app.projects[row.projectIndex]
if (row.type === "project" || row.type === "new-session") {
// Cycle tab number: none → 1 → 2 → ... → 9 → none
const current = app.selectedProjects.get(project.path)
if (current === undefined) {
app.selectedProjects.set(project.path, 1)
} else if (current < MAX_TAB_NUM) {
app.selectedProjects.set(project.path, current + 1)
} else {
app.selectedProjects.delete(project.path)
}
} else if (row.type === "session") {
toggleSetItem(app.selectedSessions, project.sessions![row.sessionIndex!].id)
} else if (row.type === "branch") {
if (app.selectedBranches.get(project.path) === row.branchName) {
app.selectedBranches.delete(project.path)
} else {
app.selectedBranches.set(project.path, row.branchName!)
}
}
}
function assignTabNumber(row: DisplayRow, tabNum: number) {
const project = app.projects[row.projectIndex]
if (row.type === "project" || row.type === "new-session") {
const current = app.selectedProjects.get(project.path)
if (current === tabNum) {
app.selectedProjects.delete(project.path) // toggle off if same number
} else {
app.selectedProjects.set(project.path, tabNum)
}
}
}
function syntheticKey(name: string, shift = false, ctrl = false): KeyEvent {
return { name, shift, ctrl, meta: false, preventDefault: NOOP, stopPropagation: NOOP } as KeyEvent
}
// ─── Collapse helper ─────────────────────────────────────────────────
function collapseProject(projectIndex: number) {
app.projects[projectIndex].expanded = false
rebuildDisplayRows()
const target = app.displayRows.findIndex(
(r) => r.type === "project" && r.projectIndex === projectIndex
)
app.cursor = target >= 0 ? target : 0
if (app.cursor >= app.displayRows.length) app.cursor = app.displayRows.length - 1
}
// ─── Expand ──────────────────────────────────────────────────────────
export async function expandProject(projectIndex: number) {
const project = app.projects[projectIndex]
if (app.demoMode) {
if (!project.sessions) {
project.sessions = generateMockSessions(project.path)
project.sessionCount = project.sessions.length
}
if (!project.branches) {
project.branches = generateMockBranches(project.path)
}
populateMockSessionStatus(project)
} else {
const loads: Promise<void>[] = []
if (!project.sessions) {
loads.push(
loadSessions(project.path).then(s => {
project.sessions = s
project.sessionCount = s.length
})
)
}
if (!project.branches) {
loads.push(
loadBranches(project.path).then(b => { project.branches = b }).catch(() => { project.branches = [] })
)
}
if (loads.length > 0) await Promise.all(loads)
}
project.expanded = true
rebuildDisplayRows()
updateAll()
}
// ─── Hit test ────────────────────────────────────────────────────────
export function hitTestListRow(screenRow: number): number {
const relY = screenRow - 2 + app.listBox.scrollTop
if (relY < 0) return -1
let y = 0
for (let i = 0; i < app.displayRows.length; i++) {
const h = app.displayRows[i].type === "session" ? 3 : 1
if (relY >= y && relY < y + h) return i
y += h
}
return -1
}
// ─── Tab switching helpers ───────────────────────────────────────────
function handleTabSwitch(tabNumber: number) {
if (tabNumber === 0) {
// Switch to picker
if (app.viewMode === "grid") switchToPicker()
return
}
// tabNumber 1-9 → grid tab index 0-8
const tabIndex = tabNumber - 1
if (tabIndex < app.gridTabs.length) {
switchToGridTab(app.gridTabs[tabIndex].id)
}
}
function handleNextTab() {
if (app.gridTabs.length === 0) return
if (app.viewMode === "picker") {
switchToGridTab(app.gridTabs[0].id)
return
}
const currentIdx = app.gridTabs.findIndex(t => t.id === app.directGrid?.activeTabId)
if (currentIdx < app.gridTabs.length - 1) {
switchToGridTab(app.gridTabs[currentIdx + 1].id)
} else {
switchToPicker() // wrap around to picker
}
}
function handlePrevTab() {
if (app.gridTabs.length === 0) return
if (app.viewMode === "picker") {
switchToGridTab(app.gridTabs[app.gridTabs.length - 1].id)
return
}
const currentIdx = app.gridTabs.findIndex(t => t.id === app.directGrid?.activeTabId)
if (currentIdx > 0) {
switchToGridTab(app.gridTabs[currentIdx - 1].id)
} else {
switchToPicker() // wrap to picker
}
}
// ─── Picker click ────────────────────────────────────────────────────
export function handlePickerClick(_col: number, screenRow: number) {
const idx = hitTestListRow(screenRow)
if (idx < 0 || idx >= app.displayRows.length) return
app.cursor = idx
toggleRowSelection(app.displayRows[idx])
updateAll()
}
// ─── Picker tab bar click ────────────────────────────────────────────
function handlePickerTabBarClick(col: number, screenRow: number) {
// Tab bar is at row 1 in picker (rendered as OpenTUI text)
if (screenRow !== 1) return false
// Hit test against tab bar positions — 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
for (const tab of app.gridTabs) {
const isActive = app.viewMode === "grid" && app.directGrid?.activeTabId === tab.id
// Build inline pane name list to calculate width
const tabPanes = app.directGrid?.getTabPanes(tab.id) ?? []
const paneNames = tabPanes.map(p => {
const name = p.session.projectName
return name.length > 14 ? name.slice(0, 12) + "…" : name
})
const inlineLabel = paneNames.length > 0 ? paneNames.join(" · ") : "empty"
const visLen = 2 + inlineLabel.length // "● " + label
const dg = app.directGrid
if (isActive) {
// Active: ╭ ● panes ×
const labelStart = c + 2
const labelEnd = labelStart + visLen - 1
const closeCol = labelEnd + 2
const totalVis = 1 + 1 + visLen + 1 + 1 + 1 + 1
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 ● panes sp × sp │
const labelStart = c + 1
const labelEnd = labelStart + visLen - 1
const closeCol = labelEnd + 2
const totalVis = 1 + visLen + 1 + 1 + 1 + 1
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 + 1 && col <= c + 3) {
createNewGridTab()
return true
}
return false
}
// ─── Keyboard ────────────────────────────────────────────────────────
export async function handleKeypress(key: KeyEvent) {
try {
const total = app.displayRows.length
if (total === 0) return
switch (key.name) {
case "up":
if (app.cursor > 0) app.cursor--
break
case "down":
if (app.cursor < total - 1) app.cursor++
break
case "pageup":
app.cursor = Math.max(0, app.cursor - 15)
break
case "pagedown":
app.cursor = Math.min(total - 1, app.cursor + 15)
break
case "home":
app.cursor = 0
break
case "end":
app.cursor = total - 1
break
case "right": {
const row = app.displayRows[app.cursor]
if (row.type === "project" && !app.projects[row.projectIndex].expanded) {
expandProject(row.projectIndex)
return
}
return
}
case "left":
collapseProject(app.displayRows[app.cursor].projectIndex)
break
case "space":
toggleRowSelection(app.displayRows[app.cursor])
break
case "f": {
const project = app.projects[app.displayRows[app.cursor].projectIndex]
Bun.spawn(["open", project.path])
break
}
case "g": {
const row = app.displayRows[app.cursor]
const project = app.projects[row.projectIndex]
// Try grid pane navigation first
if (app.directGrid && app.gridTabs.length > 0) {
const targetSessionId = row.type === "session" && project.sessions
? project.sessions[row.sessionIndex!]?.id
: undefined
for (const tab of app.gridTabs) {
const panes = app.directGrid.getTabPanes(tab.id)
const paneIdx = targetSessionId
? panes.findIndex(p => p.session.projectPath === project.path && p.session.sessionId === targetSessionId)
: panes.findIndex(p => p.session.projectPath === project.path)
if (paneIdx >= 0) {
switchToGridTab(tab.id)
app.directGrid.setFocus(paneIdx)
return
}
}
}
// Fallback: external terminal
if (project.activeSessions > 0) {
const sid = row.type === "session" && project.sessions
? project.sessions[row.sessionIndex!]?.id
: undefined
await focusTerminalByPath(project.path, sid)
}
return
}
case "a":
for (const p of app.projects) app.selectedProjects.set(p.path, 1)
break
case "n":
app.selectedProjects.clear()
app.selectedSessions.clear()
app.selectedBranches.clear()
break
case "i":
app.bottomPanelMode = app.bottomPanelMode === "preview" ? "idle" : "preview"
app.idleCursor = 0
break
case "tab":
if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0) {
const max = Math.min(app.cachedIdleSessions.length, 3)
app.idleCursor = key.shift
? (app.idleCursor > 0 ? app.idleCursor - 1 : max - 1)
: (app.idleCursor + 1) % max
}
break
case "s":
app.sortMode = (app.sortMode + 1) % app.sortLabels.length
applySortMode()
app.cursor = 0
break
case "return": {
const hasSelections = app.selectedProjects.size > 0 || app.selectedSessions.size > 0
if (hasSelections) {
if (app.addPaneTargetTabId !== null) {
doAddPane()
} else {
doLaunch()
}
break
}
if (app.addPaneTargetTabId !== null) {
// In add-pane mode with no explicit selections, add cursor item
const addRow = app.displayRows[app.cursor]
if (addRow) app.selectedProjects.set(app.projects[addRow.projectIndex].path, 1)
doAddPane()
break
}
if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0 && app.idleCursor < app.cachedIdleSessions.length) {
if (await focusTerminalByPath(app.cachedIdleSessions[app.idleCursor].projectPath)) return
}
const returnRow = app.displayRows[app.cursor]
if (returnRow.type === "project" && app.projects[returnRow.projectIndex].activeSessions > 0) {
if (await focusTerminalByPath(app.projects[returnRow.projectIndex].path)) return
}
doLaunch()
break
}
case "o": {
if (app.selectedProjects.size === 0 && app.selectedSessions.size === 0) {
const oRow = app.displayRows[app.cursor]
if (oRow) app.selectedProjects.set(app.projects[oRow.projectIndex].path, 1)
}
if (app.selectedProjects.size > 0 || app.selectedSessions.size > 0) {
await launchSelections(app.projects, app.selectedProjects, app.selectedSessions, app.selectedBranches)
app.selectedProjects.clear()
app.selectedSessions.clear()
app.selectedBranches.clear()
}
break
}
case "1": case "2": case "3": case "4": case "5":
case "6": case "7": case "8": case "9": {
const row = app.displayRows[app.cursor]
assignTabNumber(row, parseInt(key.name))
break
}
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.addPaneTargetTabId !== null) {
// Cancel add-pane mode, return to grid
const returnTabId = app.addPaneTargetTabId
app.addPaneTargetTabId = null
app.selectedProjects.clear()
app.selectedSessions.clear()
app.selectedBranches.clear()
switchToGridTab(returnTabId)
return
}
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")
app.renderer.destroy()
return
default:
return
}
updateAll()
} catch (err) { console.error("[handleKeypress]", err) }
}
// ─── Grid input ──────────────────────────────────────────────────────
export async function handleGridInput(rawSequence: string): Promise<boolean> {
if (app.viewMode !== "grid" || !app.directGrid) return false
// Esc: collapse expanded/soft-expanded, or do nothing
if (rawSequence === "\x1b") {
if (app.directGrid.isExpanded) { app.directGrid.collapsePane(); return true }
if (app.directGrid.isSoftExpanded) { app.directGrid.softCollapsePane(); return true }
return true
}
// Ctrl+Space → switch to picker
if (rawSequence === "\x00") {
app.lastGridTabIndex = app.gridTabs.findIndex(t => t.id === app.directGrid!.activeTabId)
switchToPicker()
return true
}
// Ctrl+T → new tab
if (rawSequence === "\x14") {
createNewGridTab()
return true
}
// Ctrl+S → toggle select mode (disable mouse tracking for native text selection)
if (rawSequence === "\x13") {
if (app.directGrid.selectMode) app.directGrid.exitSelectMode()
else app.directGrid.enterSelectMode()
return true
}
// Ctrl+E → toggle click-to-expand
if (rawSequence === "\x05") {
app.clickExpand = !app.clickExpand
if (!app.clickExpand && app.directGrid.isSoftExpanded) app.directGrid.softCollapsePane()
app.directGrid.drawChrome()
return true
}
// Alt+1 through Alt+9 → switch tab
if (rawSequence.length === 2 && rawSequence[0] === "\x1b" && rawSequence[1] >= "1" && rawSequence[1] <= "9") {
handleTabSwitch(parseInt(rawSequence[1]))
return true
}
// Alt+n → next tab, Alt+p → prev tab
if (rawSequence === "\x1bn") { handleNextTab(); return true }
if (rawSequence === "\x1bp") { handlePrevTab(); return true }
// Ctrl+N → add pane to current tab (enter picker in add-pane mode)
if (rawSequence === "\x0e") {
app.addPaneTargetTabId = app.directGrid.activeTabId
switchToPicker()
return true
}
// Ctrl+P → focus prev pane
if (rawSequence === "\x10") { app.directGrid.focusPrev(); return true }
// Ctrl+F → open folder
if (rawSequence === "\x06") {
const pane = app.directGrid.focusedPane
if (pane) Bun.spawn(["open", pane.session.projectPath])
return true
}
// Ctrl+W → close pane (remove tab if last pane)
if (rawSequence === "\x17") {
const pane = app.directGrid.focusedPane
if (pane) {
if (app.directGrid.isExpanded) app.directGrid.collapsePane()
if (app.directGrid.isSoftExpanded) app.directGrid.softCollapsePane()
const { killSession } = await import("../pty/session-manager")
app.directGrid.removePane(pane.session.name)
await killSession(pane.session.name)
if (app.directGrid.paneCount === 0) {
// Remove current tab and switch to previous or picker
const currentTabId = app.directGrid.activeTabId
const tabIdx = app.gridTabs.findIndex(t => t.id === currentTabId)
app.directGrid.removeTab(currentTabId)
app.gridTabs.splice(tabIdx, 1)
if (app.gridTabs.length > 0) {
const prevIdx = Math.max(0, tabIdx - 1)
switchToGridTab(app.gridTabs[prevIdx].id)
} else {
switchToPicker()
}
}
}
return true
}
// Page Up/Down → scroll
if (rawSequence === "\x1b[5~") { app.directGrid.sendScrollToFocused("up"); return true }
if (rawSequence === "\x1b[6~") { app.directGrid.sendScrollToFocused("down"); return true }
app.directGrid.sendInputToFocused(rawSequence)
return true
}
// ─── View switching ──────────────────────────────────────────────────
export function switchToPicker() {
app.viewMode = "picker"
app.activeTabIndex = 0
if (app.directGrid) {
if (app.directGrid.selectMode) app.directGrid.exitSelectMode()
if (app.directGrid.totalPaneCount > 0) app.directGrid.pause()
}
app.renderer.resume()
process.stdin.removeAllListeners("data")
process.stdin.on("data", stdinHandler)
process.stdout.write("\x1b[?1000h")
process.stdout.write("\x1b[?1006h")
if (app.mainBox) app.mainBox.visible = true
updateAll()
app.renderer.requestRender()
}
// ─── Double-click detection ──────────────────────────────────────────
let _lastClickTime = 0
let _lastClickCol = 0
let _lastClickRow = 0
const DOUBLE_CLICK_MS = 400
const DOUBLE_CLICK_DIST = 2
function isDoubleClick(col: number, row: number): boolean {
const now = Date.now()
const dt = now - _lastClickTime
const dist = Math.abs(col - _lastClickCol) + Math.abs(row - _lastClickRow)
_lastClickTime = now
_lastClickCol = col
_lastClickRow = row
return dt < DOUBLE_CLICK_MS && dist <= DOUBLE_CLICK_DIST
}
// ─── Stdin: grid mode ────────────────────────────────────────────────
function processGridInput(str: string) {
const dg = app.directGrid!
if (dg.selectMode) {
if (extractKeyboardInput(str) === "\x1b") dg.exitSelectMode()
return
}
const mouseEvents = extractMouseEvents(str)
for (const me of mouseEvents) {
if (me.btn === 64) { dg.sendScrollToFocused("up", 3); continue }
if (me.btn === 65) { dg.sendScrollToFocused("down", 3); continue }
if (me.btn === 0 && !me.release) {
// Double-click → enter select mode for native text selection
if (isDoubleClick(me.col, me.row)) { dg.enterSelectMode(); return }
const btn = dg.checkButtonClick(me.col, me.row)
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)
switchToPicker()
} else if (btn.tabId !== undefined) {
switchToGridTab(btn.tabId)
}
}
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)
if (clickedIdx >= 0 && clickedIdx !== dg.focusIndex) {
dg.softExpandPane(clickedIdx)
}
} else {
dg.focusByClick(me.col, me.row)
}
}
continue
}
}
let stripped = str
for (let i = mouseEvents.length - 1; i >= 0; i--) {
const me = mouseEvents[i]
stripped = stripped.slice(0, me.start) + stripped.slice(me.end)
}
const keyboard = extractKeyboardInput(stripped)
if (!keyboard) return
const dir = SHIFT_ARROWS[keyboard]
if (dir) dg.focusByDirection(dir)
else handleGridInput(keyboard)
}
// ─── Stdin: picker mode ──────────────────────────────────────────────
function processPickerInput(str: string) {
// Ctrl+Space → toggle to last grid tab
if (str.includes("\x00")) {
if (app.directGrid && app.directGrid.totalPaneCount > 0) {
// Switch to last active grid tab
if (app.gridTabs.length > 0) {
const idx = Math.min(app.lastGridTabIndex, app.gridTabs.length - 1)
switchToGridTab(app.gridTabs[Math.max(0, idx)].id)
}
return
}
}
const pickerMouse = extractMouseEvents(str)
for (const me of pickerMouse) {
if (me.btn === 0 && !me.release) {
if (handlePickerTabBarClick(me.col, me.row)) continue
handlePickerClick(me.col, me.row)
}
if (me.btn === 64) { if (app.cursor > 0) { app.cursor--; updateAll() } }
if (me.btn === 65) { if (app.cursor < app.displayRows.length - 1) { app.cursor++; updateAll() } }
}
const keyboard = extractKeyboardInput(str)
if (!keyboard) return
// Check for Alt+digit and Alt+n/p before normal key processing
let ki = 0
while (ki < keyboard.length) {
// Alt sequences
if (keyboard[ki] === "\x1b" && ki + 1 < keyboard.length) {
const next = keyboard[ki + 1]
if (next >= "1" && next <= "9") {
handleTabSwitch(parseInt(next))
ki += 2
continue
}
if (next === "n") { handleNextTab(); ki += 2; continue }
if (next === "p") { handlePrevTab(); ki += 2; continue }
}
let matched = false
for (let len = Math.min(8, keyboard.length - ki); len >= 1; len--) {
const mapped = KEY_MAP[keyboard.slice(ki, ki + len)]
if (mapped) {
handleKeypress(syntheticKey(mapped.name, mapped.shift, mapped.ctrl))
ki += len
matched = true
break
}
}
if (!matched) {
const code = keyboard.charCodeAt(ki)
if (code >= 0x21 && code <= 0x7e) {
handleKeypress(syntheticKey(keyboard[ki]))
}
ki++
}
}
}
// ─── 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.
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)
}

104
src/input/parser.ts Normal file
View File

@@ -0,0 +1,104 @@
// Extract only safe keyboard input from stdin data.
// WHITELIST approach: only recognized keyboard sequences pass through.
// Everything else (mouse events, terminal responses, OSC, DCS, etc.) is dropped.
export function extractKeyboardInput(data: string): string {
let keyboard = ""
let i = 0
while (i < data.length) {
const c = data.charCodeAt(i)
// ESC sequences
if (c === 0x1b) {
if (i + 1 >= data.length) { keyboard += "\x1b"; i++; continue } // lone ESC = Escape key
const next = data[i + 1]
// OSC: \x1b] ... (terminated by BEL \x07 or ST \x1b\\) — drop entirely
if (next === "]") {
let j = i + 2
while (j < data.length) {
if (data[j] === "\x07") { j++; break }
if (data[j] === "\x1b" && j + 1 < data.length && data[j + 1] === "\\") { j += 2; break }
j++
}
i = j; continue
}
// DCS: \x1bP ... ST | APC: \x1b_ ... ST | PM: \x1b^ ... ST — drop entirely
if (next === "P" || next === "_" || next === "^") {
let j = i + 2
while (j < data.length) {
if (data[j] === "\x1b" && j + 1 < data.length && data[j + 1] === "\\") { j += 2; break }
j++
}
i = j; continue
}
// CSI: \x1b[
if (next === "[") {
let j = i + 2
// Consume parameter bytes (0x30-0x3F: digits, ;, <, =, >, ?)
while (j < data.length && data.charCodeAt(j) >= 0x30 && data.charCodeAt(j) <= 0x3F) j++
// Consume intermediate bytes (0x20-0x2F)
while (j < data.length && data.charCodeAt(j) >= 0x20 && data.charCodeAt(j) <= 0x2F) j++
// Final byte (0x40-0x7E)
if (j < data.length && data.charCodeAt(j) >= 0x40 && data.charCodeAt(j) <= 0x7E) {
const final = data[j]
// Legacy X10 mouse: \x1b[M followed by 3 raw bytes (btn+32, col+32, row+32)
if (final === "M" && j === i + 2) {
i = Math.min(j + 4, data.length); continue
}
// ONLY keep: arrows (A-D), Home (H), End (F), shift-tab (Z), function keys (~)
if ("ABCDHFZ~".includes(final)) {
keyboard += data.slice(i, j + 1)
}
i = j + 1; continue
}
// Incomplete/malformed CSI — drop
i = j; continue
}
// SS3: \x1bO + letter (F1-F4, keypad)
if (next === "O" && i + 2 < data.length) {
keyboard += data.slice(i, i + 3)
i += 3; continue
}
// Alt+key combos: \x1b + printable/control char — pass through to PTY
// Includes Alt+Backspace (\x1b\x7f), Alt+digits, Alt+letters, etc.
const nc = data.charCodeAt(i + 1)
if ((nc >= 0x20 && nc <= 0x7e) || nc === 0x7f) {
keyboard += data.slice(i, i + 2)
i += 2; continue
}
// Any other \x1b+char — drop (unknown escape sequence)
i += 2; continue
}
// Regular character: printable ASCII, control chars, UTF-8 — keep
keyboard += data[i]
i++
}
return keyboard
}
// Parse SGR mouse events from raw data.
export function extractMouseEvents(data: string): { btn: number, col: number, row: number, release: boolean, start: number, end: number }[] {
const events: { btn: number, col: number, row: number, release: boolean, start: number, end: number }[] = []
const re = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g
let m
while ((m = re.exec(data)) !== null) {
events.push({
btn: parseInt(m[1]),
col: parseInt(m[2]),
row: parseInt(m[3]),
release: m[4] === "m",
start: m.index,
end: m.index + m[0].length,
})
}
return events
}

67
src/lib/state.ts Normal file
View File

@@ -0,0 +1,67 @@
import type { CliRenderer } from "@opentui/core"
import type { BoxRenderable, TextRenderable, ScrollBoxRenderable } from "@opentui/core"
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"
export type ViewMode = "picker" | "grid"
export interface GridTab {
id: number
name: string
}
export const app = {
// Config
demoMode: Bun.argv.includes("--demo"),
// Data
projects: [] as Project[],
selectedProjects: new Map<string, number>(), // path → tab number
selectedSessions: new Set<string>(),
selectedBranches: new Map<string, string>(),
cursor: 0,
sortMode: 0,
sortLabels: ["recent", "name", "commit", "sessions"] as const,
sortedIndices: [] as number[],
displayRows: [] as DisplayRow[],
// Monitor
monitorInterval: null as ReturnType<typeof setInterval> | null,
prevBusySnapshot: new Map<string, number>(),
bottomPanelMode: "preview" as "preview" | "idle",
destroyed: false,
idleCursor: 0,
cachedIdleSessions: [] as IdleSessionInfo[],
// Grid mode
viewMode: "picker" as ViewMode,
directGrid: null as DirectGridRenderer | null,
mainBox: null as BoxRenderable | null,
rawStdoutWrite: null as unknown as (s: string) => boolean,
// Tabs
activeTabIndex: 0, // 0 = picker, 1+ = grid tab index+1
gridTabs: [] as GridTab[], // grid tabs only (not picker)
nextTabId: 1, // auto-increment for tab ids
clickExpand: true, // click-to-expand feature toggle
lastGridTabIndex: 0, // last active grid tab for Ctrl+Space toggle
savedSession: null as SavedSession | null,
restoreMode: null as "pending" | null,
addPaneTargetTabId: null as number | null,
// UI refs (set during init)
renderer: null as unknown as CliRenderer,
headerText: null as unknown as TextRenderable,
tabBarText: null as unknown as TextRenderable,
paneListText: null as unknown as TextRenderable,
colHeaderText: null as unknown as TextRenderable,
listBox: null as unknown as ScrollBoxRenderable,
bottomRow: null as unknown as BoxRenderable,
previewBox: null as unknown as BoxRenderable,
previewText: null as unknown as TextRenderable,
usageBox: null as unknown as BoxRenderable,
footerText: null as unknown as TextRenderable,
cachedUsage: null as UsageSummary | null,
}

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

4
src/lib/theme.ts Normal file
View File

@@ -0,0 +1,4 @@
export const CURSOR_BG = "#283457"
export const ACTIVE_BG = "#1a2e1a"
export const ACCENT = "#7aa2f7"
export const DIM_CLR = "#565f89"

View File

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

View File

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

705
src/pty/capture.ts Normal file
View File

@@ -0,0 +1,705 @@
// PTY capture: reads raw ANSI output from pty-helper stdout,
// maintains a virtual screen buffer, and pushes frame updates to subscribers.
// Replaces tmux capture-pane — no screen-scraping, direct PTY output.
import type { PtySession } from "./session-manager"
export interface CaptureResult {
lines: string[]
cursorX: number
cursorY: number
width: number
height: number
}
type FrameCallback = (frame: CaptureResult) => void
interface PaneState {
session: PtySession
screen: VtScreen
callbacks: Set<FrameCallback>
reader: ReadableStreamDefaultReader<Uint8Array> | null
running: boolean
}
const panes = new Map<string, PaneState>()
// ─── VT Screen Buffer ─────────────────────────────────────────
interface VtCell {
char: string
sgr: string // accumulated SGR state as ANSI escape string
}
class VtScreen {
width: number
height: number
cursorX = 0
cursorY = 0
cells: VtCell[][]
scrollback: VtCell[][] = [] // lines that scrolled off the top
scrollOffset = 0 // 0 = live view, >0 = scrolled back N lines
private static MAX_SCROLLBACK = 5000
private currentSgr = ""
private savedCursorX = 0
private savedCursorY = 0
private scrollTop = 0
private scrollBottom: number
private altScreen: VtCell[][] | null = null
private altCursorX = 0
private altCursorY = 0
constructor(width: number, height: number) {
this.width = width
this.height = height
this.scrollBottom = height - 1
this.cells = this.makeGrid(width, height)
}
private makeGrid(w: number, h: number): VtCell[][] {
return Array.from({ length: h }, () =>
Array.from({ length: w }, () => ({ char: " ", sgr: "" }))
)
}
resize(width: number, height: number) {
const newCells = this.makeGrid(width, height)
for (let r = 0; r < Math.min(height, this.height); r++) {
for (let c = 0; c < Math.min(width, this.width); c++) {
newCells[r][c] = this.cells[r]?.[c] ?? { char: " ", sgr: "" }
}
}
this.cells = newCells
this.width = width
this.height = height
this.scrollTop = 0
this.scrollBottom = height - 1
if (this.cursorX >= width) this.cursorX = width - 1
if (this.cursorY >= height) this.cursorY = height - 1
}
// Write raw PTY output to the virtual screen
write(data: string) {
let i = 0
while (i < data.length) {
const c = data.charCodeAt(i)
// ESC sequences
if (c === 0x1b && i + 1 < data.length) {
const next = data[i + 1]
// CSI: \x1b[
if (next === "[") {
i = this.handleCSI(data, i + 2)
continue
}
// OSC: \x1b] ... BEL or ST — skip
if (next === "]") {
i = this.skipOSC(data, i + 2)
continue
}
// DCS/APC/PM: \x1bP, \x1b_, \x1b^ — skip to ST
if (next === "P" || next === "_" || next === "^") {
i = this.skipToST(data, i + 2)
continue
}
// SS3: \x1bO — skip the next char
if (next === "O" && i + 2 < data.length) {
i += 3
continue
}
// Save cursor: \x1b7 or \x1b[s
if (next === "7") {
this.savedCursorX = this.cursorX
this.savedCursorY = this.cursorY
i += 2; continue
}
// Restore cursor: \x1b8 or \x1b[u
if (next === "8") {
this.cursorX = this.savedCursorX
this.cursorY = this.savedCursorY
i += 2; continue
}
// Index (scroll up): \x1bD
if (next === "D") {
this.index()
i += 2; continue
}
// Reverse index (scroll down): \x1bM
if (next === "M") {
this.reverseIndex()
i += 2; continue
}
// Set tab stop, reset: skip
if (next === "H" || next === "c") {
i += 2; continue
}
// Unknown ESC — skip
i += 2; continue
}
// C0 control characters
if (c === 0x0d) { // CR
this.cursorX = 0
i++; continue
}
if (c === 0x0a) { // LF
if (this.cursorY === this.scrollBottom) {
this.scrollUp()
} else if (this.cursorY < this.height - 1) {
this.cursorY++
}
i++; continue
}
if (c === 0x08) { // BS
if (this.cursorX > 0) this.cursorX--
i++; continue
}
if (c === 0x09) { // TAB
this.cursorX = Math.min(((this.cursorX >> 3) + 1) << 3, this.width - 1)
i++; continue
}
if (c === 0x07) { // BEL
i++; continue
}
if (c < 0x20 && c !== 0x1b) {
i++; continue
}
// Printable character
if (this.cursorX >= this.width) {
// Auto-wrap
this.cursorX = 0
if (this.cursorY === this.scrollBottom) {
this.scrollUp()
} else if (this.cursorY < this.height - 1) {
this.cursorY++
}
}
const row = this.cells[this.cursorY]
if (row && this.cursorX < this.width) {
row[this.cursorX] = { char: data[i], sgr: this.currentSgr }
}
this.cursorX++
i++
}
}
// Get full buffer: all scrollback lines + current screen (for select mode)
getAllLines(): string[] {
const lines: string[] = []
for (const row of this.scrollback) lines.push(this.renderRow(row))
for (let r = 0; r < this.height; r++) lines.push(this.renderRow(this.cells[r]))
return lines
}
// Get screen as lines with embedded ANSI SGR codes (like tmux capture-pane -e)
// When scrollOffset > 0, shows scrollback history mixed with screen content
getLines(): string[] {
const lines: string[] = []
if (this.scrollOffset > 0) {
// Viewing scrollback: combine scrollback + current screen, then take a window
const sbLen = this.scrollback.length
const totalRows = sbLen + this.height
const startRow = totalRows - this.height - this.scrollOffset
for (let r = 0; r < this.height; r++) {
const srcRow = startRow + r
let row: VtCell[]
if (srcRow < 0) {
// Beyond scrollback — empty line
lines.push("")
continue
} else if (srcRow < sbLen) {
row = this.scrollback[srcRow]
} else {
row = this.cells[srcRow - sbLen]
}
lines.push(this.renderRow(row))
}
} else {
// Live view: just render current screen
for (let r = 0; r < this.height; r++) {
lines.push(this.renderRow(this.cells[r]))
}
}
return lines
}
private renderRow(row: VtCell[]): string {
if (!row) return ""
let line = ""
let lastSgr = ""
let trailingSpaces = 0
for (let c = 0; c < this.width && c < row.length; c++) {
const cell = row[c]
if (cell.sgr !== lastSgr) {
if (trailingSpaces > 0) {
line += " ".repeat(trailingSpaces)
trailingSpaces = 0
}
line += cell.sgr ? `\x1b[${cell.sgr}m` : "\x1b[0m"
lastSgr = cell.sgr
}
if (cell.char === " " && !cell.sgr) {
trailingSpaces++
} else {
if (trailingSpaces > 0) {
line += " ".repeat(trailingSpaces)
trailingSpaces = 0
}
line += cell.char
}
}
if (lastSgr) line += "\x1b[0m"
return line
}
// ─── CSI Handler ──────────────────────────────────────────
private handleCSI(data: string, start: number): number {
let i = start
const params: number[] = []
let num = ""
let privateMode = ""
// Check for private mode prefix (?, >, =)
if (i < data.length && (data[i] === "?" || data[i] === ">" || data[i] === "=")) {
privateMode = data[i]
i++
}
// Parse parameters
while (i < data.length) {
const ch = data[i]
if (ch >= "0" && ch <= "9") {
num += ch; i++
} else if (ch === ";") {
params.push(num === "" ? 0 : parseInt(num))
num = ""; i++
} else {
params.push(num === "" ? 0 : parseInt(num))
break
}
}
if (i >= data.length) return i
const final = data[i]
i++
// Private mode sequences (h/l for set/reset)
if (privateMode === "?") {
if (final === "h" || final === "l") {
const mode = params[0] ?? 0
if (mode === 1049 || mode === 47 || mode === 1047) {
if (final === "h") {
// Enter alternate screen
this.altScreen = this.cells
this.altCursorX = this.cursorX
this.altCursorY = this.cursorY
this.cells = this.makeGrid(this.width, this.height)
this.cursorX = 0
this.cursorY = 0
} else {
// Leave alternate screen
if (this.altScreen) {
this.cells = this.altScreen
this.cursorX = this.altCursorX
this.cursorY = this.altCursorY
this.altScreen = null
}
}
}
// Ignore other private modes (cursor visibility, mouse, etc.)
}
return i
}
// Standard CSI sequences
switch (final) {
case "m": // SGR
this.currentSgr = this.buildSgrString(params)
break
case "H": case "f": { // Cursor position
const row = Math.max(0, (params[0] || 1) - 1)
const col = Math.max(0, (params[1] || 1) - 1)
this.cursorY = Math.min(row, this.height - 1)
this.cursorX = Math.min(col, this.width - 1)
break
}
case "A": // Cursor up
this.cursorY = Math.max(0, this.cursorY - (params[0] || 1))
break
case "B": // Cursor down
this.cursorY = Math.min(this.height - 1, this.cursorY + (params[0] || 1))
break
case "C": // Cursor right
this.cursorX = Math.min(this.width - 1, this.cursorX + (params[0] || 1))
break
case "D": // Cursor left
this.cursorX = Math.max(0, this.cursorX - (params[0] || 1))
break
case "G": // Cursor horizontal absolute
this.cursorX = Math.min(Math.max(0, (params[0] || 1) - 1), this.width - 1)
break
case "d": // Cursor vertical absolute
this.cursorY = Math.min(Math.max(0, (params[0] || 1) - 1), this.height - 1)
break
case "J": { // Erase in display
const mode = params[0] || 0
if (mode === 0) { // Erase below
this.clearRange(this.cursorY, this.cursorX, this.height - 1, this.width - 1)
} else if (mode === 1) { // Erase above
this.clearRange(0, 0, this.cursorY, this.cursorX)
} else if (mode === 2 || mode === 3) { // Erase all
this.cells = this.makeGrid(this.width, this.height)
}
break
}
case "K": { // Erase in line
const mode = params[0] || 0
const row = this.cells[this.cursorY]
if (!row) break
if (mode === 0) { // Erase to right
for (let c = this.cursorX; c < this.width; c++) row[c] = { char: " ", sgr: "" }
} else if (mode === 1) { // Erase to left
for (let c = 0; c <= this.cursorX; c++) row[c] = { char: " ", sgr: "" }
} else if (mode === 2) { // Erase entire line
for (let c = 0; c < this.width; c++) row[c] = { char: " ", sgr: "" }
}
break
}
case "X": { // Erase characters
const n = params[0] || 1
const row = this.cells[this.cursorY]
if (row) {
for (let c = this.cursorX; c < Math.min(this.cursorX + n, this.width); c++) {
row[c] = { char: " ", sgr: "" }
}
}
break
}
case "L": { // Insert lines
const n = Math.min(params[0] || 1, this.scrollBottom - this.cursorY + 1)
for (let j = 0; j < n; j++) {
this.cells.splice(this.scrollBottom, 1)
this.cells.splice(this.cursorY, 0,
Array.from({ length: this.width }, () => ({ char: " ", sgr: "" })))
}
break
}
case "M": { // Delete lines
const n = Math.min(params[0] || 1, this.scrollBottom - this.cursorY + 1)
for (let j = 0; j < n; j++) {
this.cells.splice(this.cursorY, 1)
this.cells.splice(this.scrollBottom, 0,
Array.from({ length: this.width }, () => ({ char: " ", sgr: "" })))
}
break
}
case "P": { // Delete characters
const n = params[0] || 1
const row = this.cells[this.cursorY]
if (row) {
row.splice(this.cursorX, n)
while (row.length < this.width) row.push({ char: " ", sgr: "" })
}
break
}
case "@": { // Insert characters
const n = params[0] || 1
const row = this.cells[this.cursorY]
if (row) {
for (let j = 0; j < n; j++) {
row.splice(this.cursorX, 0, { char: " ", sgr: "" })
}
row.length = this.width
}
break
}
case "S": { // Scroll up
const n = params[0] || 1
for (let j = 0; j < n; j++) this.scrollUp()
break
}
case "T": { // Scroll down
const n = params[0] || 1
for (let j = 0; j < n; j++) this.reverseIndex()
break
}
case "r": { // Set scroll region
this.scrollTop = Math.max(0, (params[0] || 1) - 1)
this.scrollBottom = Math.min(this.height - 1, (params[1] || this.height) - 1)
this.cursorX = 0
this.cursorY = 0
break
}
case "s": // Save cursor
this.savedCursorX = this.cursorX
this.savedCursorY = this.cursorY
break
case "u": // Restore cursor
this.cursorX = this.savedCursorX
this.cursorY = this.savedCursorY
break
case "n": // Device status report — ignore
case "c": // Device attributes — ignore
case "h": case "l": // Set/reset mode — ignore
case "t": // Window manipulation — ignore
break
}
return i
}
private buildSgrString(params: number[]): string {
// Reset
if (params.length === 1 && params[0] === 0) return ""
if (params.length === 0) return ""
return params.join(";")
}
private clearRange(r1: number, c1: number, r2: number, c2: number) {
for (let r = r1; r <= r2 && r < this.height; r++) {
const row = this.cells[r]
if (!row) continue
const startC = r === r1 ? c1 : 0
const endC = r === r2 ? c2 : this.width - 1
for (let c = startC; c <= endC && c < this.width; c++) {
row[c] = { char: " ", sgr: "" }
}
}
}
private scrollUp() {
// Save the line scrolling off the top into scrollback
const removedRow = this.cells.splice(this.scrollTop, 1)[0]
if (removedRow && this.scrollTop === 0) {
this.scrollback.push(removedRow)
if (this.scrollback.length > VtScreen.MAX_SCROLLBACK) {
this.scrollback.shift()
}
}
this.cells.splice(this.scrollBottom, 0,
Array.from({ length: this.width }, () => ({ char: " ", sgr: "" })))
// Auto-reset scroll offset when new output arrives
if (this.scrollOffset > 0) this.scrollOffset = 0
}
private index() {
if (this.cursorY === this.scrollBottom) {
this.scrollUp()
} else if (this.cursorY < this.height - 1) {
this.cursorY++
}
}
private reverseIndex() {
if (this.cursorY === this.scrollTop) {
this.cells.splice(this.scrollBottom, 1)
this.cells.splice(this.scrollTop, 0,
Array.from({ length: this.width }, () => ({ char: " ", sgr: "" })))
} else if (this.cursorY > 0) {
this.cursorY--
}
}
private skipOSC(data: string, start: number): number {
let i = start
while (i < data.length) {
if (data[i] === "\x07") return i + 1
if (data[i] === "\x1b" && i + 1 < data.length && data[i + 1] === "\\") return i + 2
i++
}
return i
}
private skipToST(data: string, start: number): number {
let i = start
while (i < data.length) {
if (data[i] === "\x1b" && i + 1 < data.length && data[i + 1] === "\\") return i + 2
i++
}
return i
}
}
// ─── Public API ─────────────────────────────────────────────
export function startCapture(session: PtySession): void {
if (panes.has(session.name)) return
const screen = new VtScreen(session.width, session.height)
const state: PaneState = {
session,
screen,
callbacks: new Set(),
reader: null,
running: true,
}
panes.set(session.name, state)
// Start reading stdout from pty-helper
if (session.proc.stdout) {
const reader = session.proc.stdout.getReader()
state.reader = reader
const decoder = new TextDecoder()
;(async () => {
try {
while (state.running) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value, { stream: true })
screen.write(text)
// Push frame to subscribers
const frame: CaptureResult = {
lines: screen.getLines(),
cursorX: screen.cursorX,
cursorY: screen.cursorY,
width: screen.width,
height: screen.height,
}
for (const cb of state.callbacks) {
try { cb(frame) } catch {}
}
}
} catch {
// Process died
}
})()
}
}
export function onFrame(sessionName: string, cb: FrameCallback): () => void {
const state = panes.get(sessionName)
if (state) state.callbacks.add(cb)
return () => {
const s = panes.get(sessionName)
if (s) s.callbacks.delete(cb)
}
}
export function getLatestFrame(sessionName: string): CaptureResult | null {
const state = panes.get(sessionName)
if (!state) return null
return {
lines: state.screen.getLines(),
cursorX: state.screen.cursorX,
cursorY: state.screen.cursorY,
width: state.screen.width,
height: state.screen.height,
}
}
export function getFullBuffer(sessionName: string): string[] | null {
const state = panes.get(sessionName)
if (!state) return null
return state.screen.getAllLines()
}
export function stopCapture(sessionName: string): void {
const state = panes.get(sessionName)
if (!state) return
state.running = false
state.callbacks.clear()
if (state.reader) {
try { state.reader.cancel() } catch {}
}
panes.delete(sessionName)
}
export function resizeCapture(sessionName: string, width: number, height: number): void {
const state = panes.get(sessionName)
if (!state) return
state.screen.resize(width, height)
}
// Scroll the pane's view into scrollback history.
// Returns the new scroll offset (0 = live view).
export function scrollPane(sessionName: string, direction: "up" | "down", lines = 5): number {
const state = panes.get(sessionName)
if (!state) return 0
const screen = state.screen
const maxOffset = screen.scrollback.length
if (direction === "up") {
screen.scrollOffset = Math.min(screen.scrollOffset + lines, maxOffset)
} else {
screen.scrollOffset = Math.max(screen.scrollOffset - lines, 0)
}
// Push a frame update so the pane redraws with the new scroll position
const frame: CaptureResult = {
lines: screen.getLines(),
cursorX: screen.cursorX,
cursorY: screen.cursorY,
width: screen.width,
height: screen.height,
}
for (const cb of state.callbacks) {
try { cb(frame) } catch {}
}
return screen.scrollOffset
}
export function getScrollOffset(sessionName: string): number {
const state = panes.get(sessionName)
return state?.screen.scrollOffset ?? 0
}
export function stopAllCaptures(): void {
for (const [name] of panes) stopCapture(name)
}
// Hash for diffing — skip re-render if nothing changed
const lastHashes = new Map<string, number>()
export function hasChanged(lines: string[], key = "_default"): boolean {
let h = 5381
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
for (let j = 0; j < line.length; j++) {
h = ((h << 5) + h + line.charCodeAt(j)) | 0
}
h = ((h << 5) + h + 10) | 0
}
const prev = lastHashes.get(key)
if (prev === h) return false
lastHashes.set(key, h)
return true
}
export function resetHash(key = "_default") {
lastHashes.delete(key)
}

142
src/pty/session-manager.ts Normal file
View File

@@ -0,0 +1,142 @@
// Direct PTY session management — no tmux.
// Each session spawns a pty-helper subprocess that owns a real PTY.
// I/O flows through Bun.spawn stdin/stdout pipes.
import type { Subprocess } from "bun"
import { resolve } from "path"
export interface PtySession {
name: string
projectPath: string
projectName: string
sessionId?: string
targetBranch?: string
alive: boolean
width: number
height: number
colorIndex: number
proc: Subprocess<"pipe", "pipe", "pipe">
}
const sessions = new Map<string, PtySession>()
let colorCounter = 0
// Resolve pty-helper binary path relative to this source file
const PTY_HELPER = resolve(import.meta.dir, "..", "..", "bin", "pty-helper")
export function getSessions(): Map<string, PtySession> {
return sessions
}
export function getSessionByProject(projectPath: string): PtySession[] {
return [...sessions.values()].filter(s => s.projectPath === projectPath)
}
export async function createSession(opts: {
projectPath: string
projectName: string
sessionId?: string
targetBranch?: string
width: number
height: number
}): Promise<PtySession> {
const slug = opts.projectName.replace(/[^a-zA-Z0-9-]/g, "-").slice(0, 20)
const ts = Date.now().toString(36)
const name = `cladm-${slug}-${ts}`
const cmd = buildClaudeCmd(opts.projectPath, opts.sessionId, opts.targetBranch)
const proc = Bun.spawn([
PTY_HELPER,
String(opts.height),
String(opts.width),
"bash", "-c", cmd,
], {
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: { ...process.env, TERM: "xterm-256color", CLAUDECODE: "" },
})
// Assign color index based on project path (same project = same color)
const existingForProject = getSessionByProject(opts.projectPath)
const ci = existingForProject.length > 0
? existingForProject[0].colorIndex
: colorCounter++
const session: PtySession = {
name,
projectPath: opts.projectPath,
projectName: opts.projectName,
sessionId: opts.sessionId,
targetBranch: opts.targetBranch,
alive: true,
width: opts.width,
height: opts.height,
colorIndex: ci,
proc,
}
sessions.set(name, session)
// Monitor for exit
proc.exited.then(() => {
session.alive = false
})
return session
}
export function killSession(name: string): void {
const session = sessions.get(name)
if (!session) return
if (!session.proc.killed) {
session.proc.stdin.end()
session.proc.kill()
}
session.alive = false
sessions.delete(name)
}
export function resizeSession(name: string, width: number, height: number): void {
const session = sessions.get(name)
if (!session || !session.alive || session.proc.killed) return
session.width = width
session.height = height
// Send APC resize command: \x1b_R<rows>;<cols>\x1b\\
const resizeCmd = `\x1b_R${height};${width}\x1b\\`
try { session.proc.stdin.write(resizeCmd) } catch {}
}
export function writeToSession(name: string, data: string): void {
const session = sessions.get(name)
if (!session || !session.alive || session.proc.killed) return
try { session.proc.stdin.write(data) } catch {}
}
export function isAlive(name: string): boolean {
const session = sessions.get(name)
if (!session) return false
return session.alive && !session.proc.killed
}
export function refreshAlive(): void {
for (const [name, session] of sessions) {
if (session.proc.killed || !session.alive) {
sessions.delete(name)
}
}
}
export function cleanupAll(): void {
for (const [name] of sessions) killSession(name)
}
function buildClaudeCmd(path: string, sessionId?: string, targetBranch?: string): string {
const base = `cd '${path}' && claude --dangerously-skip-permissions`
const branchFlag = targetBranch
? ` -p "switch to branch ${targetBranch}, stash if needed"`
: ""
if (sessionId) return `${base} --resume '${sessionId}'${branchFlag}`
return `${base}${branchFlag}`
}

View File

@@ -1,3 +1,9 @@
// Captures tmux pane content via persistent streaming subprocesses.
// Push-based: notifies subscribers immediately when new frames arrive.
// Zero polling overhead on the JS side — callbacks fire on stream data.
import type { Subprocess } from "bun"
export interface CaptureResult { export interface CaptureResult {
lines: string[] lines: string[]
cursorX: number cursorX: number
@@ -6,29 +12,157 @@ export interface CaptureResult {
height: number height: number
} }
export async function capturePane(sessionName: string): Promise<CaptureResult | null> { const SEP = "%%CLADM_FRAME%%"
type FrameCallback = (frame: CaptureResult) => void
interface PaneCapture {
proc: Subprocess<"ignore", "pipe", "ignore">
latest: CaptureResult | null
buf: string
callbacks: Set<FrameCallback>
}
const panes = new Map<string, PaneCapture>()
export function startCapture(sessionName: string, intervalMs = 100): void {
if (panes.has(sessionName)) return
const script = `while true; do
tmux capture-pane -t '${sessionName}' -p -e 2>/dev/null
echo '${SEP}'
tmux display-message -t '${sessionName}' -p '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}' 2>/dev/null
echo '${SEP}END'
sleep ${(intervalMs / 1000).toFixed(3)}
done`
const proc = Bun.spawn(["sh", "-c", script], {
stdin: "ignore",
stdout: "pipe",
stderr: "ignore",
})
const state: PaneCapture = { proc, latest: null, buf: "", callbacks: new Set() }
panes.set(sessionName, state)
const reader = proc.stdout.getReader()
const decoder = new TextDecoder()
;(async () => {
try { try {
const [contentProc, infoProc] = [ while (true) {
Bun.spawn(["tmux", "capture-pane", "-t", sessionName, "-p", "-e"], { stdout: "pipe", stderr: "ignore" }), const { done, value } = await reader.read()
Bun.spawn(["tmux", "display-message", "-t", sessionName, "-p", "#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}"], { stdout: "pipe", stderr: "ignore" }), if (done) break
] state.buf += decoder.decode(value, { stream: true })
processBuffer(sessionName, state)
}
} catch {
// Process died
}
})()
}
const [contentText, infoText] = await Promise.all([ function processBuffer(sessionName: string, state: PaneCapture) {
new Response(contentProc.stdout).text(), while (true) {
new Response(infoProc.stdout).text(), const endMarker = SEP + "END"
]) const endIdx = state.buf.indexOf(endMarker)
if (endIdx < 0) break
const [codeContent, codeInfo] = await Promise.all([contentProc.exited, infoProc.exited]) const frame = state.buf.slice(0, endIdx)
if (codeContent !== 0 || codeInfo !== 0) return null state.buf = state.buf.slice(endIdx + endMarker.length)
if (state.buf.startsWith("\n")) state.buf = state.buf.slice(1)
const parts = infoText.trim().split(" ") const sepIdx = frame.indexOf(SEP)
if (sepIdx < 0) continue
const contentText = frame.slice(0, sepIdx)
const infoText = frame.slice(sepIdx + SEP.length).trim()
const parts = infoText.split(" ")
const cursorX = parseInt(parts[0]) || 0
const cursorY = parseInt(parts[1]) || 0
const width = parseInt(parts[2]) || 80
const height = parseInt(parts[3]) || 24
const lines = contentText.split("\n")
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop()
state.latest = { lines, cursorX, cursorY, width, height }
// Push to all subscribers immediately
for (const cb of state.callbacks) {
try { cb(state.latest) } catch {}
}
}
}
// Subscribe to frame updates. Returns unsubscribe function.
export function onFrame(sessionName: string, cb: FrameCallback): () => void {
const state = panes.get(sessionName)
if (state) state.callbacks.add(cb)
// Unsub looks up current state (handles setCaptureRate restarts)
return () => {
const s = panes.get(sessionName)
if (s) s.callbacks.delete(cb)
}
}
export function getLatestFrame(sessionName: string): CaptureResult | null {
return panes.get(sessionName)?.latest ?? null
}
export function stopCapture(sessionName: string): void {
const state = panes.get(sessionName)
if (!state) return
if (!state.proc.killed) state.proc.kill()
state.callbacks.clear()
panes.delete(sessionName)
}
// Change capture rate without losing subscribers
export function setCaptureRate(sessionName: string, intervalMs: number): void {
const state = panes.get(sessionName)
if (!state) return
const savedCallbacks = new Set(state.callbacks)
stopCapture(sessionName)
startCapture(sessionName, intervalMs)
const newState = panes.get(sessionName)
if (newState) {
for (const cb of savedCallbacks) newState.callbacks.add(cb)
}
}
export function stopAllCaptures(): void {
for (const [name] of panes) stopCapture(name)
}
// Legacy one-shot capture (for non-grid use)
export async function capturePane(sessionName: string): Promise<CaptureResult | null> {
const latest = getLatestFrame(sessionName)
if (latest) return latest
try {
const proc = Bun.spawn(["sh", "-c",
`tmux capture-pane -t '${sessionName}' -p -e && echo '${SEP}' && tmux display-message -t '${sessionName}' -p '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`
], { stdout: "pipe", stderr: "ignore" })
const text = await new Response(proc.stdout).text()
const code = await proc.exited
if (code !== 0) return null
const sepIdx = text.lastIndexOf(SEP)
if (sepIdx < 0) return null
const contentText = text.slice(0, sepIdx)
const infoText = text.slice(sepIdx + SEP.length).trim()
const parts = infoText.split(" ")
const cursorX = parseInt(parts[0]) || 0 const cursorX = parseInt(parts[0]) || 0
const cursorY = parseInt(parts[1]) || 0 const cursorY = parseInt(parts[1]) || 0
const width = parseInt(parts[2]) || 80 const width = parseInt(parts[2]) || 80
const height = parseInt(parts[3]) || 24 const height = parseInt(parts[3]) || 24
const lines = contentText.split("\n") const lines = contentText.split("\n")
// Remove trailing empty line from split
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop() if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop()
return { lines, cursorX, cursorY, width, height } return { lines, cursorX, cursorY, width, height }
@@ -37,17 +171,24 @@ export async function capturePane(sessionName: string): Promise<CaptureResult |
} }
} }
// Hash for diffing - skip re-render if nothing changed // Hash for diffing skip re-render if nothing changed
let lastHash = "" const lastHashes = new Map<string, number>()
export function hasChanged(lines: string[]): boolean { export function hasChanged(lines: string[], key = "_default"): boolean {
// Simple fast hash: join first+last few lines + length let h = 5381
const h = lines.length + ":" + (lines[0] || "") + (lines[lines.length - 1] || "") for (let i = 0; i < lines.length; i++) {
if (h === lastHash) return false const line = lines[i]
lastHash = h for (let j = 0; j < line.length; j++) {
h = ((h << 5) + h + line.charCodeAt(j)) | 0
}
h = ((h << 5) + h + 10) | 0
}
const prev = lastHashes.get(key)
if (prev === h) return false
lastHashes.set(key, h)
return true return true
} }
export function resetHash() { export function resetHash(key = "_default") {
lastHash = "" lastHashes.delete(key)
} }

View File

@@ -1,31 +1,77 @@
// Forwards raw terminal input sequences to a tmux session // Forwards raw terminal input to tmux sessions via a persistent shell.
// Zero process-spawn overhead per keystroke — writes to stdin of a long-lived sh.
export async function sendKeys(sessionName: string, rawSequence: string): Promise<void> { import type { Subprocess } from "bun"
// Use -l for literal text to avoid tmux key-name interpretation
// But special keys need to be sent without -l let shell: Subprocess<"ignore", "pipe", "ignore"> | null = null
function getShell() {
if (shell && !shell.killed) return shell
shell = Bun.spawn(["sh"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
return shell
}
export function sendKeys(sessionName: string, rawSequence: string): void {
const sh = getShell()
const special = mapSpecialSequence(rawSequence) const special = mapSpecialSequence(rawSequence)
let cmd: string
if (special) { if (special) {
const proc = Bun.spawn(["tmux", "send-keys", "-t", sessionName, special], { cmd = `tmux send-keys -t '${sessionName}' ${special}\n`
stdout: "ignore", stderr: "ignore",
})
await proc.exited
} else { } else {
// Literal text - send as hex to avoid escaping issues const hex = [...rawSequence].map(c => c.charCodeAt(0).toString(16).padStart(2, "0")).join(" ")
const hexBytes = [...rawSequence].map(c => { cmd = `tmux send-keys -t '${sessionName}' -H ${hex}\n`
const code = c.charCodeAt(0)
return code.toString(16).padStart(2, "0")
})
const proc = Bun.spawn(["tmux", "send-keys", "-t", sessionName, "-H", ...hexBytes], {
stdout: "ignore", stderr: "ignore",
})
await proc.exited
} }
sh.stdin.write(cmd)
}
// Forward mouse events to tmux as SGR escape sequences
export function sendMouseEvent(sessionName: string, x: number, y: number, btn: number, release: boolean): void {
const sh = getShell()
const end = release ? "m" : "M"
const seq = `\x1b[<${btn};${x};${y}${end}`
const hex = [...seq].map(c => c.charCodeAt(0).toString(16).padStart(2, "0")).join(" ")
const cmd = `tmux send-keys -t '${sessionName}' -H ${hex}\n`
sh.stdin.write(cmd)
}
// Scroll a tmux pane using copy-mode (works with any application in the pane)
export function sendScroll(sessionName: string, direction: "up" | "down", lines = 3): void {
const sh = getShell()
if (direction === "up") {
// Enter copy mode (no-op if already in it) then scroll up
sh.stdin.write(`tmux copy-mode -t '${sessionName}' 2>/dev/null; tmux send-keys -t '${sessionName}' -X -N ${lines} scroll-up 2>/dev/null\n`)
} else {
// Scroll down in copy mode; if we hit bottom, exit copy mode
sh.stdin.write(`tmux send-keys -t '${sessionName}' -X -N ${lines} scroll-down 2>/dev/null\n`)
}
}
// Exit copy mode (e.g., when user starts typing)
export function exitCopyMode(sessionName: string): void {
const sh = getShell()
sh.stdin.write(`tmux send-keys -t '${sessionName}' -X cancel 2>/dev/null\n`)
}
export function cleanupInputQueue(_sessionName: string) {
// No per-session cleanup needed with shared shell
}
export function destroyShell() {
if (shell && !shell.killed) {
shell.stdin.end()
shell.kill()
}
shell = null
} }
// Map known ANSI escape sequences to tmux key names // Map known ANSI escape sequences to tmux key names
function mapSpecialSequence(seq: string): string | null { function mapSpecialSequence(seq: string): string | null {
// Common escape sequences -> tmux key names
const MAP: Record<string, string> = { const MAP: Record<string, string> = {
"\r": "Enter", "\r": "Enter",
"\n": "Enter", "\n": "Enter",

View File

@@ -63,6 +63,12 @@ export async function createSession(opts: {
} }
sessions.set(name, session) sessions.set(name, session)
// Enable mouse mode so clicks/scrolls forward to the application
Bun.spawn(["tmux", "set", "-t", name, "mouse", "on"], {
stdout: "ignore", stderr: "ignore",
})
return session return session
} }

192
src/ui/formatters.ts Normal file
View File

@@ -0,0 +1,192 @@
import {
t,
bold,
dim,
fg,
green,
yellow,
cyan,
magenta,
} from "@opentui/core"
import { app } from "../lib/state"
import { ACCENT } from "../lib/theme"
import { getSessionStatus } from "../data/monitor"
import { timeAgo, formatSize, elapsedCompact } from "../lib/time"
export function fmtSyncIndicator(ahead: number, behind: number): string {
if (ahead === -1 && behind === -1) return "✗"
if (ahead === 0 && behind === 0) return "✓"
const parts: string[] = []
if (ahead > 0) parts.push(`${ahead}`)
if (behind > 0) parts.push(`${behind}`)
return parts.join("")
}
export const TAB_COLORS = [
cyan, // 1
green, // 2
yellow, // 3
magenta, // 4
(s: string) => fg("#ff9e64")(s), // 5
(s: string) => fg("#7dcfff")(s), // 6
(s: string) => fg("#bb9af7")(s), // 7
(s: string) => fg("#73daca")(s), // 8
(s: string) => fg("#b4f9f8")(s), // 9
]
function getGridTabBadges(projectPath: string): string {
if (!app.directGrid || app.gridTabs.length === 0) return ""
const badges: string[] = []
for (const tab of app.gridTabs) {
const panes = app.directGrid.getTabPanes(tab.id)
if (panes.some(p => p.session.projectPath === projectPath)) {
const displayIdx = app.gridTabs.indexOf(tab) + 1
const color = TAB_COLORS[(displayIdx - 1) % TAB_COLORS.length]!
badges.push(color(`T${displayIdx}`))
}
}
return badges.length > 0 ? badges.join("") + " " : ""
}
function getSessionGridTabBadge(projectPath: string, sessionId: string): string {
if (!app.directGrid || app.gridTabs.length === 0) return ""
for (const tab of app.gridTabs) {
const panes = app.directGrid.getTabPanes(tab.id)
if (panes.some(p => p.session.projectPath === projectPath && p.session.sessionId === sessionId)) {
const displayIdx = app.gridTabs.indexOf(tab) + 1
const color = TAB_COLORS[(displayIdx - 1) % TAB_COLORS.length]!
return " " + color(`T${displayIdx}`)
}
}
return ""
}
function fmtTabCheck(tabNum: number | undefined) {
if (tabNum === undefined) return " "
const color = TAB_COLORS[(tabNum - 1) % TAB_COLORS.length]!
return color(String(tabNum))
}
export function fmtProjectRow(project: import("../lib/types").Project, isSelected: number | undefined) {
let activeDot: string
let activeTag: string
if (project.activeSessions > 0) {
if (project.busySessions > 0) {
activeDot = green("●")
const count = String(project.activeSessions)
activeTag = project.activeSessions > 1 ? yellow((count + " ").slice(0, 2)) : " "
} else {
activeDot = yellow("◉")
const elapsed = elapsedCompact(project.lastActivityMs)
activeTag = elapsed ? dim((elapsed + " ").slice(0, 2)) : " "
}
} else {
activeDot = dim("○")
activeTag = " "
}
const check = fmtTabCheck(isSelected)
const arrow = project.expanded ? "▼" : "▶"
const name =
project.name.length > 28 ? project.name.slice(0, 25) + "..." : project.name
const branch =
project.branch.length > 8
? project.branch.slice(0, 7) + "…"
: project.branch
const sync = fmtSyncIndicator(project.ahead, project.behind)
const syncCol = sync === "✓" ? green(sync.padEnd(5))
: sync === "✗" ? dim(sync.padEnd(5))
: yellow(sync.padEnd(5))
const dirtyCol = project.dirty
? yellow(project.dirty.padEnd(9))
: green("clean".padEnd(9))
const ca = project.claudeAgo
let claudeCol
if (ca === "never" || ca === "-") claudeCol = dim(ca.padEnd(9))
else if (ca.includes("m ago") || ca.includes("h ago") || ca === "just now")
claudeCol = cyan(ca.padEnd(9))
else if (ca.includes("d ago")) claudeCol = green(ca.padEnd(9))
else claudeCol = dim(ca.padEnd(9))
const gridBadge = getGridTabBadges(project.path)
return t` ${activeDot}${activeTag}${gridBadge}[${check}] ${dim(arrow)} ${name.padEnd(28)} ${magenta(branch.padEnd(9))}${syncCol}${dim(
(project.commitAge || "-").padEnd(10)
)}${(project.commitMsg || "-").padEnd(22)}${dirtyCol}${claudeCol}${dim(
String(project.sessionCount).padStart(3)
)} ${dim(String(project.totalMessages).padStart(5))} ${dim(project.tags)}`
}
export function fmtSessionRow(
projectIdx: number,
sessionIdx: number,
isSelected: boolean,
isLastSession: boolean
) {
const project = app.projects[projectIdx]
const session = project.sessions![sessionIdx]
const check = isSelected ? green("✓") : " " // sessions still use boolean check
const prefix = isLastSession ? "│ " : "├─"
const title =
session.title.length > 55
? session.title.slice(0, 52) + "..."
: session.title
const age = timeAgo(session.timestamp)
const size = formatSize(session.sizeBytes)
const status = getSessionStatus(project.path, session.id)
const promptText = session.lastUserPrompt
? session.lastUserPrompt.length > 60
? session.lastUserPrompt.slice(0, 57) + "..."
: session.lastUserPrompt
: "(no text)"
const responseText = session.lastAssistantMsg
? session.lastAssistantMsg.length > 60
? session.lastAssistantMsg.slice(0, 57) + "..."
: session.lastAssistantMsg
: "(no text response)"
const tabBadge = session.id ? getSessionGridTabBadge(project.path, session.id) : ""
if (status === "busy") {
return t` ${green("●")} ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim(
size.padEnd(7)
)} ${fg(ACCENT)('"' + title + '"')} ${green("running")}${tabBadge}
${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')}
${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}`
}
if (status === "idle") {
return t` ${yellow("◉")} ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim(
size.padEnd(7)
)} ${fg(ACCENT)('"' + title + '"')} ${yellow("idle")}${tabBadge}
${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')}
${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}`
}
return t` ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim(
size.padEnd(7)
)} ${fg(ACCENT)('"' + title + '"')}
${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')}
${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}`
}
export function fmtNewSessionRow(projectIdx: number, isSelected: number | undefined) {
const check = fmtTabCheck(isSelected)
return t` ${dim("└─")} [${check}] ${green("+ New session")}`
}
export function fmtBranchRow(projectIdx: number, branchName: string, isSelected: boolean) {
const project = app.projects[projectIdx]
const br = project.branches?.find(b => b.name === branchName)
if (!br) return t` ${dim("├─")} ${branchName}`
const check = isSelected ? green("✓") : " "
const sync = fmtSyncIndicator(br.ahead, br.behind)
const syncCol = sync === "✓" ? green(sync)
: sync === "✗" ? dim(sync)
: yellow(sync)
const msg = br.lastCommitMsg.length > 40 ? br.lastCommitMsg.slice(0, 37) + "..." : br.lastCommitMsg
return t` ${dim("├─")} [${check}] ${magenta(branchName.padEnd(18))} ${syncCol} ${dim(br.lastCommitAge.padEnd(9))} ${dim(msg)}`
}

458
src/ui/panels.ts Normal file
View File

@@ -0,0 +1,458 @@
import {
Box,
Text,
t,
bold,
dim,
fg,
green,
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, timeAgoShort } from "../lib/time"
import { fmtProjectRow, fmtSessionRow, fmtNewSessionRow, fmtBranchRow, fmtSyncIndicator } from "./formatters"
// ─── Display rows ────────────────────────────────────────────────────
export function rebuildDisplayRows() {
app.displayRows = []
for (const idx of app.sortedIndices) {
const project = app.projects[idx]
app.displayRows.push({ type: "project", projectIndex: idx })
if (project.expanded) {
if (project.branches) {
for (const br of project.branches) {
if (!br.isCurrent) {
app.displayRows.push({ type: "branch", projectIndex: idx, branchName: br.name })
}
}
}
if (project.sessions) {
for (let si = 0; si < project.sessions.length; si++) {
app.displayRows.push({ type: "session", projectIndex: idx, sessionIndex: si })
}
}
app.displayRows.push({ type: "new-session", projectIndex: idx })
}
}
}
export function applySortMode() {
const indices = Array.from(app.projects.keys())
switch (app.sortMode) {
case 0:
app.sortedIndices = indices
break
case 1:
app.sortedIndices = indices.sort((a, b) =>
app.projects[a].name.localeCompare(app.projects[b].name)
)
break
case 2:
app.sortedIndices = indices.sort(
(a, b) => (app.projects[b].commitEpoch || 0) - (app.projects[a].commitEpoch || 0)
)
break
case 3:
app.sortedIndices = indices.sort(
(a, b) => app.projects[b].sessionCount - app.projects[a].sessionCount
)
break
}
rebuildDisplayRows()
}
// ─── Tab bar ─────────────────────────────────────────────────────────
const PANE_COLORS = [
"#7aa2f7", "#9ece6a", "#e0af68", "#f7768e", "#bb9af7",
"#7dcfff", "#ff9e64", "#c0caf5", "#73daca", "#b4f9f8",
]
export function updatePaneList() {
if (!app.paneListText) return
if (!app.directGrid || app.gridTabs.length === 0) {
app.paneListText.content = ""
return
}
const parts: Parameters<typeof st> = [t` `]
let first = true
for (const tab of app.gridTabs) {
const tabPanes = app.directGrid.getTabPanes(tab.id)
if (tabPanes.length === 0) continue
for (let pi = 0; pi < tabPanes.length; pi++) {
const pane = tabPanes[pi]!
const name = pane.session.projectName
const short = name.length > 14 ? name.slice(0, 12) + "…" : name
const isFocused = app.directGrid!.activeTabId === tab.id && app.directGrid!.focusIndex === pi
// Status icon: ● green=running, ◉ yellow=idle, ○ dim=unknown
const statusIcon = pane.status === "busy" ? green("● ")
: pane.status === "idle" ? yellow("◉ ")
: dim("○ ")
if (!first) parts.push(dim(" · "))
parts.push(statusIcon)
parts.push(isFocused ? bold(short) : dim(short))
first = false
}
parts.push(dim(" │ "))
first = true
}
app.paneListText.content = st(...parts)
}
export function updateTabBar() {
if (!app.tabBarText) return
const pickerActive = app.viewMode === "picker"
const sep = dim(" │ ")
// 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 — inline pane names
for (const tab of app.gridTabs) {
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 closeBtn = isPending ? t` ${red(bold("[●]"))}` : t` ${dim("[×]")}`
// Build inline pane name list
const tabPanes = app.directGrid?.getTabPanes(tab.id) ?? []
const paneNames = tabPanes.map(p => {
const name = p.session.projectName
return name.length > 14 ? name.slice(0, 12) + "…" : name
})
const inlineLabel = paneNames.length > 0 ? paneNames.join(" · ") : "empty"
if (isActive) {
parts.push(dim("╭"), t` ${cyan("●")} ${bold(inlineLabel)}`, closeBtn, t` ${dim("╮")}`)
} else if (hasIdle) {
parts.push(t` ${yellow("◉")} ${dim(inlineLabel)}`, closeBtn, " ", sep)
} else {
parts.push(t` ${dim("○ " + inlineLabel)}`, closeBtn, " ", sep)
}
}
parts.push(t` ${dim("[+]")}`)
app.tabBarText.content = st(...parts)
}
// ─── Header / Footer ─────────────────────────────────────────────────
export function updateHeader() {
const total = app.selectedProjects.size + app.selectedSessions.size
const modeLabel = app.demoMode ? " [DEMO]" : ""
// Add-pane mode: show target tab context
if (app.addPaneTargetTabId !== null) {
const targetTab = app.gridTabs.find(t => t.id === app.addPaneTargetTabId)
const tabName = targetTab?.name ?? `Tab ${app.addPaneTargetTabId}`
app.headerText.content = t` ${bold("cladm")}${yellow(modeLabel)}${cyan(bold(`Adding to: ${tabName}`))}${String(total)} selected ${dim(
`sort: ${app.sortLabels[app.sortMode]}${app.projects.length} projects`
)}`
return
}
// Count distinct tab groups
const tabGroups = new Set(app.selectedProjects.values())
const tabNote = tabGroups.size > 1 ? `${tabGroups.size} tabs` : ""
const branchNote = app.selectedBranches.size > 0 ? ` (${app.selectedBranches.size} branch switch)` : ""
const activeCount = app.projects.reduce((sum, p) => sum + (p.activeSessions > 0 ? 1 : 0), 0)
const busyCount = app.projects.reduce((sum, p) => sum + (p.busySessions > 0 ? 1 : 0), 0)
const idleCount = activeCount - busyCount
if (activeCount > 0) {
app.headerText.content = t` ${bold("cladm")}${yellow(modeLabel)}${String(total)} selected${tabNote}${branchNote} ${dim(
`sort: ${app.sortLabels[app.sortMode]}${app.projects.length} projects`
)}${green(`${busyCount} busy`)} ${yellow(`${idleCount} idle`)}`
} else {
app.headerText.content = t` ${bold("cladm")}${yellow(modeLabel)}${String(total)} selected${tabNote}${branchNote} ${dim(
`sort: ${app.sortLabels[app.sortMode]}${app.projects.length} projects`
)}`
}
}
export function updateColumnHeaders() {
const cols = ` ${"PROJECT".padEnd(30)} ${"BRANCH".padEnd(9)}${"SYNC".padEnd(5)}${"COMMIT".padEnd(10)}${"MESSAGE".padEnd(22)}${"DIRTY".padEnd(9)}${"LAST USE".padEnd(9)}${"SES".padStart(3)} ${"MSGS".padStart(5)} STACK`
app.colHeaderText.content = t` ${dim(cols)}`
}
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})`
}
// Add-pane mode: simplified footer
if (app.addPaneTargetTabId !== null) {
app.footerText.content = t` ${dim("↑↓ nav │ space select │ → expand │ ← collapse │ enter add │ esc cancel")}`
return
}
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 + 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 + restoreHint
)}`
}
}
// ─── Bottom panel ────────────────────────────────────────────────────
function addIdleRow(s: { idleSinceMs: number; projectName: string; sessionTitle: string; lastPrompt: string; lastResponse: string }, isCursor: boolean) {
const elapsed = (elapsedCompact(s.idleSinceMs) || "<5s").padEnd(6)
const name = s.projectName.length > 20 ? s.projectName.slice(0, 17) + "..." : s.projectName
const title = s.sessionTitle.length > 50 ? s.sessionTitle.slice(0, 47) + "..." : s.sessionTitle
const prompt = s.lastPrompt
? s.lastPrompt.length > 60 ? s.lastPrompt.slice(0, 57) + "..." : s.lastPrompt
: "(no text)"
const response = s.lastResponse
? s.lastResponse.length > 60 ? s.lastResponse.slice(0, 57) + "..." : s.lastResponse
: "(no response)"
const pointer = isCursor ? "▸" : " "
app.previewBox.add(Text({ content: t` ${yellow("◉")} ${isCursor ? cyan(pointer) : dim(pointer)} ${dim(elapsed)}${bold(name)} ${fg(ACCENT)('"' + title + '"')}`, width: "100%", height: 1 }))
app.previewBox.add(Text({ content: t` ${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + prompt + '"')}`, width: "100%", height: 1 }))
app.previewBox.add(Text({ content: t` ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + response + '"')}`, width: "100%", height: 1 }))
}
function updateIdlePanel() {
app.cachedIdleSessions = getIdleSessions(app.projects)
const n = app.cachedIdleSessions.length
app.previewBox.title = ` Idle Sessions (${n}) — enter to focus `
clearChildren(app.previewBox)
if (n === 0) {
app.idleCursor = 0
app.previewBox.add(Text({ content: t`${dim(" No idle sessions")}`, width: "100%", height: 1 }))
return
}
if (app.idleCursor >= n) app.idleCursor = n - 1
const show = app.cachedIdleSessions.slice(0, 3)
for (let i = 0; i < show.length; i++) {
addIdleRow(show[i], app.idleCursor === i)
}
if (n > 3) {
app.previewBox.add(Text({ content: t` ${dim(`+${n - 3} more`)}`, width: "100%", height: 1 }))
}
}
export function updateBottomPanel() {
if (app.bottomPanelMode === "idle") {
app.bottomRow.height = 14
updateIdlePanel()
} else {
clearChildren(app.previewBox)
app.previewBox.add(app.previewText)
app.bottomRow.height = 10
app.previewBox.title = " Preview "
updatePreview()
}
}
// ─── Usage panel ─────────────────────────────────────────────────────
function usageBarColor(p: number) {
return p >= 80 ? yellow : p >= 50 ? cyan : green
}
export function updateUsagePanel() {
if (app.destroyed) return
clearChildren(app.usageBox)
if (!app.cachedUsage) {
app.usageBox.title = " Usage "
app.usageBox.add(Text({ content: t`${dim("Loading...")}`, width: "100%", height: 1 }))
return
}
const u = app.cachedUsage
const BAR_W = 18
const sPct = pct(u.totalCost, PLAN_LIMITS.session)
const sBar = makeBar(u.totalCost, PLAN_LIMITS.session, BAR_W)
const sReset = u.sessionResetMs > 0 ? formatWindow(u.sessionResetMs) : ""
app.usageBox.title = " Usage "
app.usageBox.add(Text({ content: t`${bold("Session")}`, width: "100%", height: 1 }))
app.usageBox.add(Text({ content: t`${usageBarColor(sPct)(sBar)} ${bold(String(sPct) + "%")}`, width: "100%", height: 1 }))
app.usageBox.add(Text({ content: t`${dim(sReset ? "resets " + sReset : "")} ${dim(formatCost(u.costPerHour) + "/h")}`, width: "100%", height: 1 }))
const wPct = pct(u.weekTotal, PLAN_LIMITS.weeklyAll)
const wBar = makeBar(u.weekTotal, PLAN_LIMITS.weeklyAll, BAR_W)
app.usageBox.add(Text({ content: t`${bold("All models")} ${dim(formatCost(u.weekTotal))}`, width: "100%", height: 1 }))
app.usageBox.add(Text({ content: t`${usageBarColor(wPct)(wBar)} ${bold(String(wPct) + "%")}`, width: "100%", height: 1 }))
const snPct = pct(u.weeklySonnetCost, PLAN_LIMITS.weeklySonnet)
const snBar = makeBar(u.weeklySonnetCost, PLAN_LIMITS.weeklySonnet, BAR_W)
app.usageBox.add(Text({ content: t`${bold("Sonnet")} ${dim(formatCost(u.weeklySonnetCost))}`, width: "100%", height: 1 }))
app.usageBox.add(Text({ content: t`${usageBarColor(snPct)(snBar)} ${bold(String(snPct) + "%")}`, width: "100%", height: 1 }))
const monthLabel = new Date().toLocaleString("en", { month: "short" })
app.usageBox.add(Text({ content: t`${bold(monthLabel + " total")} ${dim(formatCost(u.monthlyTotalCost))}`, width: "100%", height: 1 }))
app.usageBox.add(Text({ content: t`${dim(formatCost(u.costPerHour) + "/h avg · " + u.totalRequests + " reqs")}`, width: "100%", height: 1 }))
app.renderer.requestRender()
}
// ─── Preview panel ───────────────────────────────────────────────────
export function updatePreview() {
if (app.cursor >= app.displayRows.length) {
app.previewText.content = t`${dim(" No selection")}`
return
}
const row = app.displayRows[app.cursor]
const project = app.projects[row.projectIndex]
if (row.type === "project") {
app.previewText.content = t` ${bold(project.name)} ${dim(project.path)}
${dim("Branch:")} ${magenta(project.branch)} ${dim("Commit:")} ${
project.commitAge || "-"
}${project.commitMsg || "-"}
${dim("Status:")} ${project.dirty ? yellow(project.dirty) : green("clean")} ${dim(
"Sessions:"
)} ${String(project.sessionCount)} ${dim("Msgs:")} ${String(project.totalMessages)} ${dim(
"Stack:"
)} ${project.tags || "-"}`
} else if (row.type === "session" && project.sessions) {
const s = project.sessions[row.sessionIndex!]
const sStatus = getSessionStatus(project.path, s.id)
const sLabel = sStatus === "busy" ? green(" ● running") : sStatus === "idle" ? yellow(" ◉ idle") : ""
app.previewText.content = t` ${bold("Session:")} ${s.title}${sLabel}
${dim(timeAgo(s.timestamp))} · ${dim(formatSize(s.sizeBytes))} · ${magenta(s.branch || "-")}
${dim("Last prompt:")} ${s.lastUserPrompt || dim("(no text)")}
${dim("Claude:")} ${s.lastAssistantMsg || dim("(no text response)")}`
} else if (row.type === "branch" && project.branches) {
const br = project.branches.find(b => b.name === row.branchName)
if (br) {
const sync = fmtSyncIndicator(br.ahead, br.behind)
const selBranch = app.selectedBranches.get(project.path)
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 = st(
t` ${bold("Branch:")} ${magenta(br.name)} ${dim("Sync:")} ${sync}
${dim("Last commit:")} ${br.lastCommitAge}${br.lastCommitMsg}
`, selNote)
}
} else {
app.previewText.content = t` ${green("Start a new Claude session")} in ${bold(project.name)}
${dim(project.path)}`
}
}
// ─── List rendering ──────────────────────────────────────────────────
// Renderable IDs for each row — enables incremental updates
let rowRenderableIds: string[] = []
function renderRowContent(i: number) {
const row = app.displayRows[i]
const project = app.projects[row.projectIndex]
let content: ReturnType<typeof t>
let rowHeight = 1
if (row.type === "project") {
content = fmtProjectRow(project, app.selectedProjects.get(project.path))
} else if (row.type === "session") {
content = fmtSessionRow(row.projectIndex, row.sessionIndex!, app.selectedSessions.has(project.sessions![row.sessionIndex!].id), false)
rowHeight = 3
} else if (row.type === "branch") {
content = fmtBranchRow(row.projectIndex, row.branchName!, app.selectedBranches.get(project.path) === row.branchName)
} else {
content = fmtNewSessionRow(row.projectIndex, app.selectedProjects.get(project.path))
}
const isCursor = i === app.cursor
const isActiveProject = row.type === "project" && project.activeSessions > 0
const isActiveSession = row.type === "session" && getSessionStatus(project.path, project.sessions![row.sessionIndex!].id) !== null
const bgColor = isCursor ? CURSOR_BG : (isActiveProject || isActiveSession) ? ACTIVE_BG : undefined
if (bgColor) {
return Box({ backgroundColor: bgColor, shouldFill: true, width: "100%", height: rowHeight }, Text({ content }))
}
return Text({ content, width: "100%", height: rowHeight })
}
export function rebuildList() {
clearChildren(app.listBox)
rowRenderableIds = []
for (let i = 0; i < app.displayRows.length; i++) {
const vnode = renderRowContent(i)
const rid = app.listBox.add(vnode)
rowRenderableIds.push(rid as unknown as string)
}
ensureCursorVisible()
app.renderer.requestRender()
}
export function ensureCursorVisible() {
const vpH = app.listBox.viewport.height
if (vpH <= 0) return
let cursorY = 0
let cursorH = 1
for (let i = 0; i < app.displayRows.length; i++) {
const h = app.displayRows[i].type === "session" ? 3 : 1
if (i === app.cursor) {
cursorH = h
break
}
cursorY += h
}
const top = app.listBox.scrollTop
if (cursorY < top) {
app.listBox.scrollTo(cursorY)
} else if (cursorY + cursorH > top + vpH) {
app.listBox.scrollTo(cursorY + cursorH - vpH)
}
}
// ─── Helpers ─────────────────────────────────────────────────────────
function clearChildren(box: { getChildren(): { id: string }[]; remove(id: string): void }) {
for (const child of box.getChildren()) box.remove(child.id)
}
// ─── Top-level ───────────────────────────────────────────────────────
export function updateAll() {
if (app.destroyed) return
updateTabBar()
updatePaneList()
updateHeader()
rebuildList()
updateBottomPanel()
updateFooter()
}