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:
5
bun.lock
5
bun.lock
@@ -6,6 +6,7 @@
|
||||
"name": "tui-claude-director",
|
||||
"dependencies": {
|
||||
"@opentui/core": "^0.1.81",
|
||||
"node-pty": "^1.1.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
@@ -146,6 +147,10 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentui/core": "^0.1.81"
|
||||
"@opentui/core": "^0.1.81",
|
||||
"node-pty": "^1.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
165
src/actions/launch.ts
Normal file
165
src/actions/launch.ts
Normal 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()
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Project } from "../lib/types"
|
||||
import { loadSessions } from "../data/sessions"
|
||||
|
||||
interface LaunchItem {
|
||||
path: string
|
||||
@@ -8,18 +9,27 @@ interface LaunchItem {
|
||||
|
||||
export async function launchSelections(
|
||||
projects: Project[],
|
||||
selectedProjects: Set<string>,
|
||||
selectedProjects: Map<string, number>,
|
||||
selectedSessions: Set<string>,
|
||||
selectedBranches: Map<string, string> = new Map()
|
||||
): Promise<number> {
|
||||
const byProject = new Map<string, LaunchItem[]>()
|
||||
|
||||
for (const path of selectedProjects) {
|
||||
for (const [path] of selectedProjects) {
|
||||
if (!byProject.has(path)) byProject.set(path, [])
|
||||
const targetBranch = selectedBranches.get(path)
|
||||
const project = projects.find(p => p.path === path)
|
||||
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) {
|
||||
@@ -40,7 +50,7 @@ export async function launchSelections(
|
||||
|
||||
let count = 0
|
||||
for (const [, items] of byProject) {
|
||||
const first = items[0]
|
||||
const first = items[0]!
|
||||
const firstCmd = buildCmd(first)
|
||||
|
||||
const newWindowScript = [
|
||||
@@ -55,7 +65,7 @@ export async function launchSelections(
|
||||
|
||||
for (let i = 1; i < items.length; i++) {
|
||||
await Bun.sleep(400)
|
||||
const cmd = buildCmd(items[i])
|
||||
const cmd = buildCmd(items[i]!)
|
||||
|
||||
await runOsascript(
|
||||
'tell application "System Events" to keystroke "t" using command down'
|
||||
|
||||
1101
src/components/direct-grid.ts
Normal file
1101
src/components/direct-grid.ts
Normal file
File diff suppressed because it is too large
Load Diff
74
src/components/direct-pane.ts
Normal file
74
src/components/direct-pane.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,31 @@ import {
|
||||
} from "@opentui/core"
|
||||
import { TerminalView, getProjectColor } from "./terminal-view"
|
||||
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 {
|
||||
session: TmuxSession
|
||||
termView: TerminalView
|
||||
borderBox: BoxRenderable
|
||||
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 {
|
||||
@@ -25,17 +43,19 @@ export class SessionGrid {
|
||||
private panes: GridPane[] = []
|
||||
private _focusIndex = 0
|
||||
private flashTimers = new Map<string, ReturnType<typeof setInterval>>()
|
||||
private titleTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
constructor(renderer: CliRenderer, container: BoxRenderable) {
|
||||
this.renderer = renderer
|
||||
this.container = container
|
||||
this.titleTimer = setInterval(() => this.refreshTitles(), 1000)
|
||||
}
|
||||
|
||||
get focusIndex() { return this._focusIndex }
|
||||
get paneCount() { return this.panes.length }
|
||||
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 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)}`) : ""}`
|
||||
|
||||
// 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 termView = new TerminalView(this.renderer, {
|
||||
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(subtitleText)
|
||||
borderBox.add(termView)
|
||||
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)
|
||||
|
||||
termView.attach(session)
|
||||
@@ -114,10 +143,110 @@ export class SessionGrid {
|
||||
}
|
||||
}
|
||||
|
||||
async sendInputToFocused(rawSequence: string) {
|
||||
flashFocused() {
|
||||
const pane = this.focusedPane
|
||||
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)
|
||||
@@ -159,34 +288,69 @@ export class SessionGrid {
|
||||
markIdle(sessionName: string) {
|
||||
const pane = this.panes.find(p => p.session.name === sessionName)
|
||||
if (!pane) return
|
||||
if (pane.status !== "idle") {
|
||||
pane.status = "idle"
|
||||
pane.statusSince = Date.now()
|
||||
}
|
||||
this.startFlash(sessionName)
|
||||
pane.titleText.content = t` ${bold(fg(getProjectColor(pane.session.colorIndex))(pane.session.projectName))} ${fg("#e0af68")("NEEDS INPUT")}`
|
||||
this.renderer.requestRender()
|
||||
this.renderPaneTitle(pane)
|
||||
}
|
||||
|
||||
markBusy(sessionName: string) {
|
||||
const pane = this.panes.find(p => p.session.name === sessionName)
|
||||
if (!pane) return
|
||||
if (pane.status !== "busy") {
|
||||
pane.status = "busy"
|
||||
pane.statusSince = Date.now()
|
||||
}
|
||||
this.clearFlash(sessionName)
|
||||
pane.titleText.content = t` ${bold(fg(getProjectColor(pane.session.colorIndex))(pane.session.projectName))} ${fg("#9ece6a")("RUNNING")}`
|
||||
this.renderer.requestRender()
|
||||
this.renderPaneTitle(pane)
|
||||
}
|
||||
|
||||
clearMark(sessionName: string) {
|
||||
const pane = this.panes.find(p => p.session.name === sessionName)
|
||||
if (!pane) return
|
||||
pane.status = null
|
||||
pane.statusSince = 0
|
||||
this.clearFlash(sessionName)
|
||||
this.renderPaneTitle(pane)
|
||||
}
|
||||
|
||||
private renderPaneTitle(pane: GridPane) {
|
||||
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()
|
||||
}
|
||||
|
||||
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() {
|
||||
for (let i = 0; i < this.panes.length; i++) {
|
||||
const pane = this.panes[i]
|
||||
const isFocused = i === this._focusIndex
|
||||
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
|
||||
if (isFocused) {
|
||||
pane.borderBox.borderColor = RGBA.fromHex("#ffffff")
|
||||
@@ -241,6 +405,7 @@ export class SessionGrid {
|
||||
}
|
||||
|
||||
destroyAll() {
|
||||
if (this.titleTimer) { clearInterval(this.titleTimer); this.titleTimer = null }
|
||||
for (const timer of this.flashTimers.values()) clearInterval(timer)
|
||||
this.flashTimers.clear()
|
||||
for (const pane of this.panes) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
RGBA,
|
||||
TextAttributes,
|
||||
} 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 type { TmuxSession } from "../tmux/session-manager"
|
||||
|
||||
@@ -28,34 +28,52 @@ export function getProjectColor(colorIndex: number): string {
|
||||
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 {
|
||||
session: TmuxSession | null = null
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
private unsubCapture: (() => void) | null = null
|
||||
private lastFrame: ParsedFrame | null = null
|
||||
private _focused = false
|
||||
private _flashUntil = 0 // timestamp until which border flashes
|
||||
private _flashUntil = 0
|
||||
private _idleSince = 0
|
||||
private _frameDirty = false
|
||||
|
||||
constructor(ctx: RenderContext, options: FrameBufferOptions) {
|
||||
super(ctx, options)
|
||||
}
|
||||
|
||||
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 }
|
||||
|
||||
attach(session: TmuxSession) {
|
||||
this.detach()
|
||||
this.session = session
|
||||
resetHash()
|
||||
this.startPolling()
|
||||
resetHash(session.name)
|
||||
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() {
|
||||
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.lastFrame = null
|
||||
this._frameDirty = false
|
||||
}
|
||||
|
||||
flash(durationMs = 2000) {
|
||||
@@ -70,29 +88,19 @@ export class TerminalView extends FrameBufferRenderable {
|
||||
this._idleSince = 0
|
||||
}
|
||||
|
||||
private startPolling() {
|
||||
if (this.pollTimer) return
|
||||
this.pollTimer = setInterval(() => this.refresh(), 80)
|
||||
// Hint that input was sent — request immediate render
|
||||
nudge() {
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
private stopPolling() {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer)
|
||||
this.pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private async refresh() {
|
||||
private onNewFrame(result: CaptureResult) {
|
||||
if (!this.session) return
|
||||
|
||||
const result = await capturePane(this.session.name)
|
||||
if (!result) return
|
||||
|
||||
if (!hasChanged(result.lines)) return
|
||||
if (!hasChanged(result.lines, this.session.name)) return
|
||||
|
||||
const frame = parseAnsiFrame(result.lines, result.width, result.height)
|
||||
this.lastFrame = frame
|
||||
this.renderFrameToBuffer(frame)
|
||||
this._frameDirty = true
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
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) {
|
||||
if (this.lastFrame) {
|
||||
if (this._frameDirty && this.lastFrame) {
|
||||
this.renderFrameToBuffer(this.lastFrame)
|
||||
this._frameDirty = false
|
||||
}
|
||||
super.renderSelf(buffer)
|
||||
}
|
||||
|
||||
protected onResize(width: number, height: number) {
|
||||
super.onResize(width, height)
|
||||
this._frameDirty = true // Re-render frame to new buffer size
|
||||
if (this.session) {
|
||||
// Resize tmux pane to match (async, fire-and-forget)
|
||||
import("../tmux/session-manager").then(m => {
|
||||
if (this.session) m.resizePane(this.session.name, width, height)
|
||||
})
|
||||
|
||||
@@ -312,15 +312,40 @@ export function updateProjectSessions(projects: Project[], sessions: Map<string,
|
||||
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(
|
||||
projects: Project[],
|
||||
prevBusy: Map<string, number>
|
||||
): string[] {
|
||||
const now = Date.now()
|
||||
const transitioned: string[] = []
|
||||
for (const project of projects) {
|
||||
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)
|
||||
pendingIdle.delete(project.path)
|
||||
notifiedIdle.add(project.path)
|
||||
}
|
||||
}
|
||||
return transitioned
|
||||
|
||||
119
src/data/session-store.ts
Normal file
119
src/data/session-store.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "fs"
|
||||
import { join, dirname } from "path"
|
||||
import { app } from "../lib/state"
|
||||
import type { SavedSession, SavedTab, SavedPane } from "../lib/types"
|
||||
import { createSession } from "../pty/session-manager"
|
||||
import { ensureGridView, switchToGridTab } from "../grid/view-switch"
|
||||
|
||||
const SESSION_PATH = join(process.env.HOME ?? "", ".config", "cladm", "session.json")
|
||||
|
||||
export function extractSessionState(): SavedSession | null {
|
||||
const dg = app.directGrid
|
||||
if (!dg || app.gridTabs.length === 0) return null
|
||||
|
||||
const tabs: SavedTab[] = []
|
||||
for (const tab of app.gridTabs) {
|
||||
const paneInfos = dg.getTabPanes(tab.id)
|
||||
const panes: SavedPane[] = []
|
||||
for (const p of paneInfos) {
|
||||
if (!p.session.alive) continue
|
||||
panes.push({
|
||||
projectPath: p.session.projectPath,
|
||||
projectName: p.session.projectName,
|
||||
sessionId: p.session.sessionId,
|
||||
targetBranch: p.session.targetBranch,
|
||||
})
|
||||
}
|
||||
if (panes.length > 0) {
|
||||
tabs.push({ id: tab.id, name: tab.name, panes })
|
||||
}
|
||||
}
|
||||
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
const activeIdx = app.gridTabs.findIndex(t => t.id === dg.activeTabId)
|
||||
return {
|
||||
version: 1,
|
||||
savedAt: Date.now(),
|
||||
activeTabIndex: Math.max(0, activeIdx),
|
||||
nextTabId: app.nextTabId,
|
||||
tabs,
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSessionSync(data: SavedSession): void {
|
||||
const dir = dirname(SESSION_PATH)
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(SESSION_PATH, JSON.stringify(data, null, 2))
|
||||
}
|
||||
|
||||
export async function loadSavedSession(): Promise<SavedSession | null> {
|
||||
try {
|
||||
const file = Bun.file(SESSION_PATH)
|
||||
if (!await file.exists()) return null
|
||||
const data = await file.json() as SavedSession
|
||||
if (data.version !== 1 || !Array.isArray(data.tabs)) return null
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteSavedSession(): void {
|
||||
try { unlinkSync(SESSION_PATH) } catch {}
|
||||
}
|
||||
|
||||
export async function restoreSession(saved: SavedSession, useResume: boolean): Promise<void> {
|
||||
ensureGridView()
|
||||
|
||||
const termW = process.stdout.columns || 120
|
||||
const termH = process.stdout.rows || 40
|
||||
|
||||
let firstTabId: number | null = null
|
||||
|
||||
for (const savedTab of saved.tabs) {
|
||||
const tabId = app.nextTabId++
|
||||
const tab = { id: tabId, name: savedTab.name }
|
||||
app.gridTabs.push(tab)
|
||||
app.directGrid!.addTab(tab)
|
||||
if (firstTabId === null) firstTabId = tabId
|
||||
|
||||
const validPanes = savedTab.panes.filter(p => existsSync(p.projectPath))
|
||||
const n = validPanes.length
|
||||
const cols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : 3
|
||||
const rows = Math.ceil(n / cols)
|
||||
const paneW = Math.max(Math.floor(termW / cols) - 2, 20)
|
||||
const paneH = Math.max(Math.floor((termH - 2) / rows) - 4, 6)
|
||||
|
||||
for (const pane of validPanes) {
|
||||
const session = await createSession({
|
||||
projectPath: pane.projectPath,
|
||||
projectName: pane.projectName,
|
||||
sessionId: useResume ? pane.sessionId : undefined,
|
||||
targetBranch: pane.targetBranch,
|
||||
width: paneW,
|
||||
height: paneH,
|
||||
})
|
||||
await app.directGrid!.addPane(session, tabId)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort tabs by name
|
||||
app.gridTabs.sort((a, b) => {
|
||||
const na = parseInt(a.name.replace(/\D/g, "")) || 0
|
||||
const nb = parseInt(b.name.replace(/\D/g, "")) || 0
|
||||
return na - nb
|
||||
})
|
||||
|
||||
// Switch to saved active tab
|
||||
const targetIdx = Math.min(saved.activeTabIndex, app.gridTabs.length - 1)
|
||||
if (targetIdx >= 0 && app.gridTabs[targetIdx]) {
|
||||
switchToGridTab(app.gridTabs[targetIdx].id)
|
||||
} else if (firstTabId !== null) {
|
||||
switchToGridTab(firstTabId)
|
||||
}
|
||||
|
||||
deleteSavedSession()
|
||||
app.savedSession = null
|
||||
app.restoreMode = null
|
||||
}
|
||||
71
src/grid/view-switch.ts
Normal file
71
src/grid/view-switch.ts
Normal 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()
|
||||
}
|
||||
1116
src/index.ts
1116
src/index.ts
File diff suppressed because it is too large
Load Diff
900
src/input/handlers.ts
Normal file
900
src/input/handlers.ts
Normal 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
104
src/input/parser.ts
Normal 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
67
src/lib/state.ts
Normal 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
20
src/lib/styled.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { StyledText } from "@opentui/core"
|
||||
|
||||
type Chunk = { __isChunk: true; text: string; attributes: number; fg?: unknown; bg?: unknown }
|
||||
type StyledPart = string | StyledText | Chunk
|
||||
|
||||
// Concatenate styled text parts into a single StyledText.
|
||||
// OpenTUI's t`` tag doesn't handle StyledText interpolation — it calls
|
||||
// toString() which produces "[object Object]". This helper merges chunks
|
||||
// from multiple t`` results, TextChunks, and plain strings.
|
||||
export function st(...parts: StyledPart[]): StyledText {
|
||||
const chunks: Chunk[] = []
|
||||
for (const p of parts) {
|
||||
if (p instanceof StyledText) chunks.push(...p.chunks)
|
||||
else if (p && typeof p === "object" && "__isChunk" in p) chunks.push(p as Chunk)
|
||||
else if (typeof p === "string") {
|
||||
if (p.length > 0) chunks.push({ __isChunk: true, text: p, attributes: 0 } as Chunk)
|
||||
}
|
||||
}
|
||||
return new StyledText(chunks)
|
||||
}
|
||||
4
src/lib/theme.ts
Normal file
4
src/lib/theme.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const CURSOR_BG = "#283457"
|
||||
export const ACTIVE_BG = "#1a2e1a"
|
||||
export const ACCENT = "#7aa2f7"
|
||||
export const DIM_CLR = "#565f89"
|
||||
@@ -20,6 +20,15 @@ export function elapsedCompact(ms: number): string {
|
||||
return `${Math.floor(sec / 86400)}d`
|
||||
}
|
||||
|
||||
export function timeAgoShort(ms: number): string {
|
||||
if (!ms) return ""
|
||||
const diff = Math.floor((Date.now() - ms) / 1000)
|
||||
if (diff < 60) return "0m ago"
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||
return `${Math.floor(diff / 86400)}d ago`
|
||||
}
|
||||
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
|
||||
@@ -46,3 +46,24 @@ export interface DisplayRow {
|
||||
sessionIndex?: number
|
||||
branchName?: string
|
||||
}
|
||||
|
||||
export interface SavedPane {
|
||||
projectPath: string
|
||||
projectName: string
|
||||
sessionId?: string
|
||||
targetBranch?: string
|
||||
}
|
||||
|
||||
export interface SavedTab {
|
||||
id: number
|
||||
name: string
|
||||
panes: SavedPane[]
|
||||
}
|
||||
|
||||
export interface SavedSession {
|
||||
version: 1
|
||||
savedAt: number
|
||||
activeTabIndex: number
|
||||
nextTabId: number
|
||||
tabs: SavedTab[]
|
||||
}
|
||||
|
||||
705
src/pty/capture.ts
Normal file
705
src/pty/capture.ts
Normal 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
142
src/pty/session-manager.ts
Normal 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}`
|
||||
}
|
||||
@@ -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 {
|
||||
lines: string[]
|
||||
cursorX: number
|
||||
@@ -6,29 +12,157 @@ export interface CaptureResult {
|
||||
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 {
|
||||
const [contentProc, infoProc] = [
|
||||
Bun.spawn(["tmux", "capture-pane", "-t", sessionName, "-p", "-e"], { stdout: "pipe", stderr: "ignore" }),
|
||||
Bun.spawn(["tmux", "display-message", "-t", sessionName, "-p", "#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}"], { stdout: "pipe", stderr: "ignore" }),
|
||||
]
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
state.buf += decoder.decode(value, { stream: true })
|
||||
processBuffer(sessionName, state)
|
||||
}
|
||||
} catch {
|
||||
// Process died
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
const [contentText, infoText] = await Promise.all([
|
||||
new Response(contentProc.stdout).text(),
|
||||
new Response(infoProc.stdout).text(),
|
||||
])
|
||||
function processBuffer(sessionName: string, state: PaneCapture) {
|
||||
while (true) {
|
||||
const endMarker = SEP + "END"
|
||||
const endIdx = state.buf.indexOf(endMarker)
|
||||
if (endIdx < 0) break
|
||||
|
||||
const [codeContent, codeInfo] = await Promise.all([contentProc.exited, infoProc.exited])
|
||||
if (codeContent !== 0 || codeInfo !== 0) return null
|
||||
const frame = state.buf.slice(0, endIdx)
|
||||
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 cursorY = parseInt(parts[1]) || 0
|
||||
const width = parseInt(parts[2]) || 80
|
||||
const height = parseInt(parts[3]) || 24
|
||||
|
||||
const lines = contentText.split("\n")
|
||||
// Remove trailing empty line from split
|
||||
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop()
|
||||
|
||||
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
|
||||
let lastHash = ""
|
||||
// Hash for diffing — skip re-render if nothing changed
|
||||
const lastHashes = new Map<string, number>()
|
||||
|
||||
export function hasChanged(lines: string[]): boolean {
|
||||
// Simple fast hash: join first+last few lines + length
|
||||
const h = lines.length + ":" + (lines[0] || "") + (lines[lines.length - 1] || "")
|
||||
if (h === lastHash) return false
|
||||
lastHash = h
|
||||
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() {
|
||||
lastHash = ""
|
||||
export function resetHash(key = "_default") {
|
||||
lastHashes.delete(key)
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
// Use -l for literal text to avoid tmux key-name interpretation
|
||||
// But special keys need to be sent without -l
|
||||
import type { Subprocess } from "bun"
|
||||
|
||||
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)
|
||||
|
||||
let cmd: string
|
||||
if (special) {
|
||||
const proc = Bun.spawn(["tmux", "send-keys", "-t", sessionName, special], {
|
||||
stdout: "ignore", stderr: "ignore",
|
||||
})
|
||||
await proc.exited
|
||||
cmd = `tmux send-keys -t '${sessionName}' ${special}\n`
|
||||
} else {
|
||||
// Literal text - send as hex to avoid escaping issues
|
||||
const hexBytes = [...rawSequence].map(c => {
|
||||
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
|
||||
const hex = [...rawSequence].map(c => c.charCodeAt(0).toString(16).padStart(2, "0")).join(" ")
|
||||
cmd = `tmux send-keys -t '${sessionName}' -H ${hex}\n`
|
||||
}
|
||||
|
||||
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
|
||||
function mapSpecialSequence(seq: string): string | null {
|
||||
// Common escape sequences -> tmux key names
|
||||
const MAP: Record<string, string> = {
|
||||
"\r": "Enter",
|
||||
"\n": "Enter",
|
||||
|
||||
@@ -63,6 +63,12 @@ export async function createSession(opts: {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
192
src/ui/formatters.ts
Normal file
192
src/ui/formatters.ts
Normal 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
458
src/ui/panels.ts
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user