diff --git a/bun.lock b/bun.lock index a8bbe7f..1946f60 100644 --- a/bun.lock +++ b/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=="], diff --git a/package.json b/package.json index fd05060..f47b484 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "typescript": "^5" }, "dependencies": { - "@opentui/core": "^0.1.81" + "@opentui/core": "^0.1.81", + "node-pty": "^1.1.0" } } diff --git a/src/actions/launcher.ts b/src/actions/launcher.ts index 5c66bdd..260a49a 100644 --- a/src/actions/launcher.ts +++ b/src/actions/launcher.ts @@ -1,4 +1,5 @@ import type { Project } from "../lib/types" +import { loadSessions } from "../data/sessions" interface LaunchItem { path: string @@ -19,7 +20,16 @@ export async function launchSelections( 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) { diff --git a/src/components/direct-grid.ts b/src/components/direct-grid.ts new file mode 100644 index 0000000..84ec3a4 --- /dev/null +++ b/src/components/direct-grid.ts @@ -0,0 +1,538 @@ +// Direct grid renderer: bypasses OpenTUI entirely for grid mode. +// Draws chrome (borders/titles) and pane content using raw ANSI cursor-addressed writes. +// Each pane renders independently via PTY capture push callbacks. + +import { DirectPane } from "./direct-pane" +import { startCapture, stopCapture, resizeCapture, resetHash, getLatestFrame, scrollPane, getScrollOffset } from "../pty/capture" +import { writeToSession, resizeSession, killSession, type PtySession } from "../pty/session-manager" + +export type PaneStatus = "busy" | "idle" | null + +export interface GridPaneInfo { + session: PtySession + directPane: DirectPane + status: PaneStatus + statusSince: number +} + +const PROJECT_COLORS = [ + "#7aa2f7", "#9ece6a", "#e0af68", "#f7768e", "#bb9af7", + "#7dcfff", "#ff9e64", "#c0caf5", "#73daca", "#b4f9f8", +] + +function getColor(idx: number): string { + return PROJECT_COLORS[idx % PROJECT_COLORS.length]! +} + +function hexFg(hex: string): string { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return `\x1b[38;2;${r};${g};${b}m` +} + +const RESET = "\x1b[0m" +const BOLD = "\x1b[1m" +const DIM = "\x1b[2m" +const WHITE = "\x1b[38;2;255;255;255m" +const HIDE_CURSOR = "\x1b[?25l" +const SHOW_CURSOR = "\x1b[?25h" +const SYNC_START = "\x1b[?2026h" +const SYNC_END = "\x1b[?2026l" +const CLEAR = "\x1b[2J\x1b[H" + +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 DirectGridRenderer { + private panes: GridPaneInfo[] = [] + private _focusIndex = 0 + private writeRaw: (s: string) => boolean + private flashTimers = new Map>() + private titleTimer: ReturnType | null = null + private running = false + private _selectMode = false + private _expandedIndex = -1 // -1 = grid view, >=0 = expanded pane index + + constructor(rawWrite: (s: string) => boolean) { + this.writeRaw = rawWrite + } + + // ─── Lifecycle ───────────────────────────────────────── + + start() { + this.running = true + this.writeRaw(HIDE_CURSOR + CLEAR) + this.drawChrome() + this.titleTimer = setInterval(() => this.refreshTitles(), 1000) + } + + stop() { + this.running = false + if (this.titleTimer) { clearInterval(this.titleTimer); this.titleTimer = null } + for (const timer of this.flashTimers.values()) clearInterval(timer) + this.flashTimers.clear() + for (const p of this.panes) { + p.directPane.detach() + stopCapture(p.session.name) + } + this.writeRaw(SHOW_CURSOR) + } + + // ─── Getters ─────────────────────────────────────────── + + get focusIndex() { return this._focusIndex } + get paneCount() { return this.panes.length } + get focusedPane(): GridPaneInfo | null { return this.panes[this._focusIndex] ?? null } + get selectMode() { return this._selectMode } + get isExpanded() { return this._expandedIndex >= 0 } + + enterSelectMode() { + if (!this.isExpanded) return // Only allow in expanded mode + this._selectMode = true + this.writeRaw("\x1b[?1000l\x1b[?1006l") // Disable mouse reporting + this.writeRaw(SHOW_CURSOR) + this.drawChrome() + } + + exitSelectMode() { + this._selectMode = false + this.writeRaw("\x1b[?1000h\x1b[?1006h") // Re-enable mouse reporting + this.writeRaw(HIDE_CURSOR) + this.drawChrome() + } + + expandPane(index?: number) { + const idx = index ?? this._focusIndex + if (idx < 0 || idx >= this.panes.length) return + this._expandedIndex = idx + this._focusIndex = idx + this.repositionAll() + } + + collapsePane() { + if (this._selectMode) this.exitSelectMode() + this._expandedIndex = -1 + this.repositionAll() + } + + // Check if a click hit a button on the top border. Returns action + pane index. + checkButtonClick(col: number, row: number): { action: "max" | "min" | "sel", paneIndex: number } | null { + const indicesToCheck = this.isExpanded ? [this._expandedIndex] : this.panes.map((_, i) => i) + for (const i of indicesToCheck) { + const dp = this.panes[i]!.directPane + const bx = dp.screenX - 1 + const by = dp.screenY - 3 + const bw = dp.width + 2 + const btnRow = by + + if (row !== btnRow) continue + + if (this.isExpanded) { + // Expanded: buttons are [SEL] and [MIN] at top-right + // Layout: ...hz [SEL] hz [MIN] hz tr + const minRight = bx + bw - 2 + const minLeft = minRight - 4 + if (col >= minLeft && col <= minRight) return { action: "min", paneIndex: i } + const selRight = minLeft - 2 + const selLeft = selRight - 4 + if (col >= selLeft && col <= selRight) return { action: "sel", paneIndex: i } + } else { + // Grid: button is [MAX] at top-right + const btnLeft = bx + bw - 7 + const btnRight = bx + bw - 3 + if (col >= btnLeft && col <= btnRight) return { action: "max", paneIndex: i } + } + } + return null + } + + // ─── Pane management ─────────────────────────────────── + + async addPane(session: PtySession): Promise { + const regions = this.calcPaneRegions(this.panes.length + 1) + const idx = this.panes.length + const region = regions[idx]! + + const dp = new DirectPane(region.screenX, region.screenY, region.contentW, region.contentH) + const info: GridPaneInfo = { session, directPane: dp, status: null, statusSince: 0 } + this.panes.push(info) + + // Resize PTY to match content area + resizeSession(session.name, region.contentW, region.contentH) + + // Start capture (reads PTY stdout and pushes frames) + startCapture(session) + + // Subscribe to push frames (must set callback AFTER attach, since attach calls detach which nulls onFrame) + dp.attach(session.name) + dp.onFrame = (lines) => { + if (!this.running) return + this.drawPane(idx, lines) + } + + // Reposition all existing panes + this.repositionAll() + + return info + } + + removePane(sessionName: string) { + const idx = this.panes.findIndex(p => p.session.name === sessionName) + if (idx < 0) return + + const pane = this.panes[idx]! + pane.directPane.detach() + stopCapture(pane.session.name) + killSession(pane.session.name) + this.clearFlash(sessionName) + this.panes.splice(idx, 1) + + if (this._focusIndex >= this.panes.length) { + this._focusIndex = Math.max(0, this.panes.length - 1) + } + + this.repositionAll() + } + + // ─── Focus ───────────────────────────────────────────── + + setFocus(index: number) { + if (index < 0 || index >= this.panes.length) return + this._focusIndex = index + this.drawChrome() + } + + focusNext() { + if (this.panes.length === 0) return + this.setFocus((this._focusIndex + 1) % this.panes.length) + } + + focusPrev() { + if (this.panes.length === 0) return + this.setFocus((this._focusIndex - 1 + this.panes.length) % this.panes.length) + } + + focusByDirection(dir: "up" | "down" | "left" | "right") { + const n = this.panes.length + if (n <= 1) return + const { cols } = this.calcGrid(n) + const rows = Math.ceil(n / cols) + const curCol = this._focusIndex % cols + const curRow = Math.floor(this._focusIndex / cols) + let nc = curCol, nr = curRow + switch (dir) { + case "left": nc = (curCol - 1 + cols) % cols; break + case "right": nc = (curCol + 1) % cols; break + case "up": nr = (curRow - 1 + rows) % rows; break + case "down": nr = (curRow + 1) % rows; break + } + const idx = nr * cols + nc + if (idx >= 0 && idx < n) this.setFocus(idx) + } + + 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 } = this.calcGrid(n) + const rows = Math.ceil(n / cols) + const cellW = Math.floor(termW / cols) + const cellH = Math.floor((termH - 2) / rows) + const gc = Math.floor((col - 1) / cellW) + const gr = Math.floor((row - 2) / cellH) + const idx = gr * cols + gc + if (idx >= 0 && idx < n) { + this.setFocus(idx) + return true + } + return false + } + + // ─── Chrome ──────────────────────────────────────────── + + drawChrome() { + if (!this.running) return + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + + let out = SYNC_START + + // Header (row 1) + const n = this.panes.length + const fi = this._focusIndex + 1 + let headerLeft: string, headerRight: string + if (this._selectMode) { + headerLeft = ` ${BOLD}cladm grid${RESET} — ${hexFg("#9ece6a")}${BOLD}SELECT MODE${RESET}` + headerRight = `${DIM}drag to select │ cmd+c copy │ ${BOLD}Esc${RESET}${DIM} exit select${RESET}` + } else if (this.isExpanded) { + headerLeft = ` ${BOLD}cladm grid${RESET} — ${hexFg("#7dcfff")}${BOLD}EXPANDED${RESET} │ ${fi}/${n}` + headerRight = `${DIM}click ${BOLD}[SEL]${RESET}${DIM} select text │ click ${BOLD}[MIN]${RESET}${DIM} restore │ ctrl+\` picker${RESET}` + } else { + headerLeft = ` ${BOLD}cladm grid${RESET} — ${n} sessions │ focus: ${fi}/${n}` + headerRight = `${DIM}shift+arrows nav │ scroll/pgup/dn │ click ${BOLD}[MAX]${RESET}${DIM} expand │ ctrl+\` picker │ ctrl+w close${RESET}` + } + out += `\x1b[1;1H\x1b[${termW}X${headerLeft} ${headerRight}` + + // Pane borders + titles + if (this.isExpanded) { + out += this.drawPaneBorder(this._expandedIndex) + } else { + for (let i = 0; i < this.panes.length; i++) { + out += this.drawPaneBorder(i) + } + } + + // Footer (last row) + const pane = this.focusedPane + if (this._selectMode) { + out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg("#9ece6a")}${BOLD}SELECT MODE${RESET} ${DIM}drag to select text │ cmd+c to copy │ press ${BOLD}Esc${RESET}${DIM} to exit${RESET}` + } else if (this.isExpanded && pane) { + const color = getColor(pane.session.colorIndex) + out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg(color)}▸${RESET} ${BOLD}${pane.session.projectName}${RESET} ${DIM}expanded │ Esc or [MIN] to restore grid${RESET}` + } else if (pane) { + const color = getColor(pane.session.colorIndex) + const sid = pane.session.sessionId ? ` ${DIM}#${pane.session.sessionId.slice(0, 8)}${RESET}` : "" + out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg(color)}▸${RESET} ${BOLD}${pane.session.projectName}${RESET}${sid} ${DIM}all input goes to focused pane${RESET}` + } else { + out += `\x1b[${termH};1H\x1b[${termW}X ${DIM}No sessions. Press ctrl+\` to return to picker.${RESET}` + } + + out += SYNC_END + this.writeRaw(out) + } + + private drawPaneBorder(index: number): string { + const pane = this.panes[index]! + const dp = pane.directPane + const isFocused = index === this._focusIndex + const isFlashing = this.flashTimers.has(pane.session.name) + + const color = getColor(pane.session.colorIndex) + let borderColor: string + if (isFocused) borderColor = WHITE + else if (isFlashing) borderColor = hexFg("#ff9e64") + else borderColor = hexFg(color) + + const tl = isFocused ? "┏" : "╭" + const tr = isFocused ? "┓" : "╮" + const bl = isFocused ? "┗" : "╰" + const br = isFocused ? "┛" : "╯" + const hz = isFocused ? "━" : "─" + const vt = isFocused ? "┃" : "│" + + const bx = dp.screenX - 1 + const by = dp.screenY - 3 + const bw = dp.width + 2 + const bh = dp.height + 4 + + let out = "" + + // Top border with buttons + let btnSection: string + let btnVisibleLen: number + if (this.isExpanded) { + // Expanded: [SEL] [MIN] at right + const selColor = this._selectMode ? `${hexFg("#9ece6a")}${BOLD}` : `${DIM}` + btnSection = `${RESET}${selColor}[SEL]${RESET}${borderColor}${hz}${RESET}${hexFg("#7dcfff")}[MIN]${RESET}${borderColor}` + btnVisibleLen = 5 + 1 + 5 // [SEL] + hz + [MIN] + } else { + // Grid: [MAX] at right + btnSection = `${RESET}${DIM}[MAX]${RESET}${borderColor}` + btnVisibleLen = 5 // [MAX] + } + const hzFill = Math.max(0, bw - 2 - btnVisibleLen - 1) // -2 corners, -1 trailing hz + out += `\x1b[${by};${bx}H${borderColor}${tl}${hz.repeat(hzFill)}${btnSection}${hz}${tr}${RESET}` + + // Title row + const nameColor = hexFg(color) + const name = pane.session.projectName + const elapsed = pane.statusSince ? fmtElapsed(pane.statusSince) : "" + const scrollOff = getScrollOffset(pane.session.name) + const scrollTag = scrollOff > 0 ? ` ${hexFg("#ff9e64")}[SCROLL +${scrollOff}]${RESET}` : "" + let titleContent: string + if (pane.status === "idle") { + titleContent = ` ${nameColor}${BOLD}${name}${RESET} ${hexFg("#e0af68")}IDLE${RESET} ${DIM}${elapsed}${RESET}${scrollTag}` + } else if (pane.status === "busy") { + titleContent = ` ${nameColor}${BOLD}${name}${RESET} ${hexFg("#9ece6a")}RUNNING${RESET} ${DIM}${elapsed}${RESET}${scrollTag}` + } else { + const sid = pane.session.sessionId ? ` ${DIM}#${pane.session.sessionId.slice(0, 8)}${RESET}` : "" + titleContent = ` ${nameColor}${BOLD}${name}${RESET}${sid}${scrollTag}` + } + out += `\x1b[${by + 1};${bx}H${borderColor}${vt}${RESET}\x1b[${bw - 2}X${titleContent}` + out += `\x1b[${by + 1};${bx + bw - 1}H${borderColor}${vt}${RESET}` + + // Subtitle row + out += `\x1b[${by + 2};${bx}H${borderColor}${vt}${RESET}\x1b[${bw - 2}X ${DIM}${pane.session.projectPath}${RESET}` + out += `\x1b[${by + 2};${bx + bw - 1}H${borderColor}${vt}${RESET}` + + // Side borders for content rows + for (let r = 0; r < dp.height; r++) { + out += `\x1b[${dp.screenY + r};${bx}H${borderColor}${vt}${RESET}` + out += `\x1b[${dp.screenY + r};${bx + bw - 1}H${borderColor}${vt}${RESET}` + } + + // Bottom border + out += `\x1b[${by + bh - 1};${bx}H${borderColor}${bl}${hz.repeat(bw - 2)}${br}${RESET}` + + return out + } + + // ─── Content rendering ───────────────────────────────── + + private drawPane(index: number, lines: string[]) { + // In expanded mode, only draw the expanded pane + if (this.isExpanded && index !== this._expandedIndex) return + const pane = this.panes[index] + if (!pane) return + const frame = pane.directPane.buildFrame(lines) + this.writeRaw(SYNC_START + frame + SYNC_END) + } + + // ─── Input ───────────────────────────────────────────── + + sendInputToFocused(rawSequence: string) { + const pane = this.focusedPane + if (!pane) return + // Reset scroll offset when user types (back to live view) + const offset = getScrollOffset(pane.session.name) + if (offset > 0) { + scrollPane(pane.session.name, "down", offset) + this.drawChrome() + } + writeToSession(pane.session.name, rawSequence) + } + + sendScrollToFocused(direction: "up" | "down", lines = 5) { + const pane = this.focusedPane + if (!pane) return + const offset = scrollPane(pane.session.name, direction, lines) + // Update title to show scroll indicator + this.drawChrome() + } + + // ─── Status ──────────────────────────────────────────── + + 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) + this.drawChrome() + } + + 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) + this.drawChrome() + } + + 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.drawChrome() + } + + startFlash(sessionName: string) { + if (this.flashTimers.has(sessionName)) return + const timer = setInterval(() => this.drawChrome(), 400) + this.flashTimers.set(sessionName, timer) + } + + clearFlash(sessionName: string) { + const timer = this.flashTimers.get(sessionName) + if (timer) { clearInterval(timer); this.flashTimers.delete(sessionName) } + } + + // ─── Layout ──────────────────────────────────────────── + + private calcGrid(n?: number): { cols: number, rows: number } { + const count = n ?? this.panes.length + const cols = count <= 1 ? 1 : count <= 2 ? 2 : count <= 4 ? 2 : count <= 6 ? 3 : count <= 9 ? 3 : 4 + return { cols, rows: Math.ceil(count / cols) } + } + + private calcPaneRegions(count?: number): { screenX: number, screenY: number, contentW: number, contentH: number }[] { + const n = count ?? this.panes.length + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const { cols, rows } = this.calcGrid(n) + const cellW = Math.floor(termW / cols) + const cellH = Math.floor((termH - 2) / rows) + + const regions: { screenX: number, screenY: number, contentW: number, contentH: number }[] = [] + for (let i = 0; i < n; i++) { + const gc = i % cols + const gr = Math.floor(i / cols) + const contentW = cellW - 2 + const contentH = cellH - 4 + const screenX = gc * cellW + 2 + const screenY = 2 + gr * cellH + 3 + regions.push({ + screenX, + screenY, + contentW: Math.max(contentW, 10), + contentH: Math.max(contentH, 2), + }) + } + return regions + } + + repositionAll() { + if (this.isExpanded) { + // Expanded: give the expanded pane full screen area + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const contentW = termW - 2 + const contentH = termH - 2 - 4 // -2 header/footer, -4 border chrome + const pane = this.panes[this._expandedIndex]! + pane.directPane.reposition(2, 5, Math.max(contentW, 10), Math.max(contentH, 2)) + resizeSession(pane.session.name, Math.max(contentW, 10), Math.max(contentH, 2)) + resizeCapture(pane.session.name, Math.max(contentW, 10), Math.max(contentH, 2)) + resetHash(`dp_${pane.session.name}`) + } else { + const regions = this.calcPaneRegions() + for (let i = 0; i < this.panes.length; i++) { + const pane = this.panes[i]! + const region = regions[i]! + pane.directPane.reposition(region.screenX, region.screenY, region.contentW, region.contentH) + resizeSession(pane.session.name, region.contentW, region.contentH) + resizeCapture(pane.session.name, region.contentW, region.contentH) + resetHash(`dp_${pane.session.name}`) + } + } + if (this.running) { + this.writeRaw(CLEAR) + this.drawChrome() + } + } + + private refreshTitles() { + let needsDraw = false + for (const pane of this.panes) { + if (pane.status && pane.statusSince) needsDraw = true + } + if (needsDraw) this.drawChrome() + } + + destroyAll() { + this.stop() + this.panes = [] + this._focusIndex = 0 + } +} diff --git a/src/components/direct-pane.ts b/src/components/direct-pane.ts new file mode 100644 index 0000000..bb9fe12 --- /dev/null +++ b/src/components/direct-pane.ts @@ -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 + } +} diff --git a/src/components/session-grid.ts b/src/components/session-grid.ts index 0304bdd..ea6b253 100644 --- a/src/components/session-grid.ts +++ b/src/components/session-grid.ts @@ -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>() + private titleTimer: ReturnType | 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): 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) { diff --git a/src/components/terminal-view.ts b/src/components/terminal-view.ts index 901077d..38e2dbf 100644 --- a/src/components/terminal-view.ts +++ b/src/components/terminal-view.ts @@ -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 | 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) }) diff --git a/src/index.ts b/src/index.ts index 584a872..468fd65 100755 --- a/src/index.ts +++ b/src/index.ts @@ -24,9 +24,9 @@ import { generateMockProjects, generateMockSessions, generateMockBranches, gener import { detectActiveSessions, updateProjectSessions, generateMockActiveSessions, focusTerminalByPath, checkTransitions, snapshotBusy, playDoneSound, bounceDock, getSessionStatus, populateMockSessionStatus, getIdleSessions } from "./data/monitor" import { getUsageSummary, formatCost, formatWindow, makeBar, pct, PLAN_LIMITS, type UsageSummary } from "./data/usage" import { launchSelections } from "./actions/launcher" -import { createSession, getSessions, refreshAlive, type TmuxSession } from "./tmux/session-manager" -import { SessionGrid } from "./components/session-grid" -import { getProjectColor } from "./components/terminal-view" +import { createSession, getSessions, refreshAlive, type PtySession } from "./pty/session-manager" +import { stopAllCaptures } from "./pty/capture" +import { DirectGridRenderer } from "./components/direct-grid" import type { Project, DisplayRow } from "./lib/types" import { timeAgo, formatSize, elapsedCompact } from "./lib/time" @@ -57,11 +57,9 @@ let cachedIdleSessions: import("./data/monitor").IdleSessionInfo[] = [] // ─── Grid Mode State ─────────────────────────────────────────────── type ViewMode = "picker" | "grid" let viewMode: ViewMode = "picker" -let sessionGrid: SessionGrid | null = null -let gridContainer: BoxRenderable | null = null -let gridHeader: TextRenderable | null = null -let gridFooter: TextRenderable | null = null +let directGrid: DirectGridRenderer | null = null let mainBox: BoxRenderable | null = null +let rawStdoutWrite: (s: string) => boolean // ─── UI Refs ──────────────────────────────────────────────────────── let renderer: CliRenderer @@ -384,7 +382,7 @@ function updateFooter() { )}` } else { footerText.content = t` ${dim( - "↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter launch │ q quit" + "↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter grid │ o external │ q quit" )}` } } @@ -518,6 +516,82 @@ function updateAll() { updateFooter() } +// ─── Mouse Parsing ────────────────────────────────────────────────── +interface MouseEvent { btn: number; col: number; row: number; release: boolean } + +function parseMouseEvent(seq: string): MouseEvent | null { + // SGR encoding: \x1b[= 6 && seq.startsWith("\x1b[M")) { + const raw = seq.charCodeAt(3) - 32 + const col = seq.charCodeAt(4) - 32 + const row = seq.charCodeAt(5) - 32 + // In legacy mode, btn 3 = release, lower 2 bits = button + const release = (raw & 3) === 3 + const btn = release ? 0 : (raw & 3) + // Scroll: bit 6 set means scroll. 64 = scroll up, 65 = scroll down + const scroll = raw & 64 + return { btn: scroll ? (raw & 67) : btn, col, row, release: release && !scroll } + } + + return null +} + +// ─── Mouse ────────────────────────────────────────────────────────── +function hitTestListRow(screenRow: number): number { + // List starts at row 2 (header=0, colHeader=1) + const listStartY = 2 + const relY = screenRow - listStartY + listBox.scrollTop + if (relY < 0) return -1 + + let y = 0 + for (let i = 0; i < displayRows.length; i++) { + const h = displayRows[i].type === "session" ? 3 : 1 + if (relY >= y && relY < y + h) return i + y += h + } + return -1 +} + +function handlePickerClick(_col: number, screenRow: number) { + const idx = hitTestListRow(screenRow) + if (idx < 0 || idx >= displayRows.length) return + + cursor = idx + const row = displayRows[idx] + const project = projects[row.projectIndex] + + // Toggle selection (same as space key) + if (row.type === "project" || row.type === "new-session") { + const path = project.path + if (selectedProjects.has(path)) selectedProjects.delete(path) + else selectedProjects.add(path) + } else if (row.type === "session") { + const session = project.sessions![row.sessionIndex!] + if (selectedSessions.has(session.id)) selectedSessions.delete(session.id) + else selectedSessions.add(session.id) + } else if (row.type === "branch") { + const path = project.path + if (selectedBranches.get(path) === row.branchName) { + selectedBranches.delete(path) + } else { + selectedBranches.set(path, row.branchName!) + } + } + + updateAll() +} + // ─── Keyboard ─────────────────────────────────────────────────────── async function handleKeypress(key: KeyEvent) { try { @@ -597,7 +671,6 @@ async function handleKeypress(key: KeyEvent) { selectedBranches.set(path, row.branchName!) } } - if (cursor < total - 1) cursor++ break } @@ -676,16 +749,36 @@ async function handleKeypress(key: KeyEvent) { break } + case "o": { + // Open selected projects in external Terminal.app windows + const hasOSel = selectedProjects.size > 0 || selectedSessions.size > 0 + if (!hasOSel) { + // If nothing selected, select current row's project + const oRow = displayRows[cursor] + if (oRow) selectedProjects.add(projects[oRow.projectIndex].path) + } + if (selectedProjects.size > 0 || selectedSessions.size > 0) { + await launchSelections(projects, selectedProjects, selectedSessions, selectedBranches) + selectedProjects.clear() + selectedSessions.clear() + selectedBranches.clear() + } + break + } + case "q": case "escape": destroyed = true if (monitorInterval) clearInterval(monitorInterval) + stopAllCaptures() + process.stdout.write("\x1b[?1006l") + process.stdout.write("\x1b[?1000l") renderer.destroy() return case "t": - // Switch to grid view if there are tmux sessions - if (sessionGrid && sessionGrid.paneCount > 0) { + // Switch to grid view if there are panes + if (directGrid && directGrid.paneCount > 0) { switchToGrid() return } @@ -751,7 +844,13 @@ async function doLaunch() { if (!project) continue const targetBranch = selectedBranches.get(path) const needsBranch = targetBranch && targetBranch !== project.branch - items.push({ path, name: project.name, targetBranch: needsBranch ? targetBranch : undefined }) + // Auto-resume most recent session (loaded lazily if needed) + 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 projects) { @@ -767,16 +866,17 @@ async function doLaunch() { if (items.length === 0) return - // Create tmux sessions and switch to grid view + // Create PTY sessions and switch to grid view ensureGridView() + // DirectGridRenderer calculates pane sizes internally const termW = process.stdout.columns || 120 const termH = process.stdout.rows || 40 - const n = items.length + (sessionGrid?.paneCount || 0) - const cols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : 3 - const rows = Math.ceil(n / cols) - const paneW = Math.floor(termW / cols) - 2 - const paneH = Math.floor((termH - 4) / rows) - 3 + const totalPanes = items.length + (directGrid?.paneCount || 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({ @@ -784,102 +884,76 @@ async function doLaunch() { projectName: item.name, sessionId: item.sessionId, targetBranch: item.targetBranch, - width: Math.max(paneW, 20), - height: Math.max(paneH, 6), + width: paneW, + height: paneH, }) - sessionGrid!.addSession(session) + await directGrid!.addPane(session) } selectedProjects.clear() selectedSessions.clear() selectedBranches.clear() - updateGridHeader() - updateGridFooter() - renderer.requestRender() } -// ─── Grid View ───────────────────────────────────────────────────── +// ─── Grid View (Direct Renderer) ──────────────────────────────────── function ensureGridView() { - if (viewMode === "grid" && sessionGrid) return + if (viewMode === "grid" && directGrid) return switchToGrid() } function switchToGrid() { viewMode = "grid" - if (mainBox) mainBox.visible = false - if (!gridContainer) { - gridHeader = new TextRenderable(renderer, { - width: "100%", - height: 1, - flexShrink: 0, - }) - - gridContainer = new BoxRenderable(renderer, { - flexDirection: "row", - flexWrap: "wrap", - flexGrow: 1, - width: "100%", - overflow: "hidden", - }) - - gridFooter = new TextRenderable(renderer, { - width: "100%", - height: 1, - flexShrink: 0, - }) - - const gridRoot = new BoxRenderable(renderer, { - flexDirection: "column", - width: "100%", - height: "100%", - }) - gridRoot.add(gridHeader!) - gridRoot.add(gridContainer!) - gridRoot.add(gridFooter!) - renderer.root.add(gridRoot) - - sessionGrid = new SessionGrid(renderer, gridContainer) + if (!directGrid) { + directGrid = new DirectGridRenderer(rawStdoutWrite) } - if (gridContainer) gridContainer.visible = true - if (gridHeader) gridHeader.visible = true - if (gridFooter) gridFooter.visible = true - updateGridHeader() - updateGridFooter() - renderer.requestRender() + // Suspend OpenTUI render loop — this pauses stdin, exits raw mode, disables mouse + renderer.suspend() + + // Restore terminal state for direct rendering + if (process.stdin.isTTY) process.stdin.setRawMode(true) + process.stdin.resume() // Re-enable stdin data events (suspend pauses them) + rawStdoutWrite("\x1b[?1049h") // Alternate screen + // Enable mouse reporting in grid mode — only for scroll wheel and click-to-focus. + // With direct PTY (no tmux), mouse events stay in our stdin handler and never leak to subprocesses. + rawStdoutWrite("\x1b[?1000h") // Basic mouse tracking (clicks + scroll) + rawStdoutWrite("\x1b[?1006h") // SGR extended mouse coordinates + directGrid.start() } function switchToPicker() { viewMode = "picker" + if (directGrid) { + if (directGrid.selectMode) directGrid.exitSelectMode() + if (directGrid.paneCount > 0) directGrid.stop() + } + // Resume OpenTUI — it will re-enter alternate screen and redraw + renderer.resume() + // Remove any data listeners OpenTUI re-adds during resume (we handle stdin ourselves) + process.stdin.removeAllListeners("data") + process.stdin.on("data", stdinHandler) + // Re-enable mouse reporting (resume may not restore it) + process.stdout.write("\x1b[?1000h") + process.stdout.write("\x1b[?1006h") if (mainBox) mainBox.visible = true - if (gridContainer) gridContainer.visible = false - if (gridHeader) gridHeader.visible = false - if (gridFooter) gridFooter.visible = false updateAll() renderer.requestRender() } -function updateGridHeader() { - if (!gridHeader) return - const n = sessionGrid?.paneCount || 0 - const fi = (sessionGrid?.focusIndex ?? 0) + 1 - gridHeader.content = t` ${bold("cladm grid")} — ${String(n)} sessions │ focus: ${String(fi)}/${String(n)} ${dim("ctrl+` picker │ ctrl+n/p switch │ ctrl+w close")}` -} - -function updateGridFooter() { - if (!gridFooter || !sessionGrid) return - const pane = sessionGrid.focusedPane - if (pane) { - const color = getProjectColor(pane.session.colorIndex) - gridFooter.content = t` ${fg(color)("▸")} ${bold(pane.session.projectName)}${pane.session.sessionId ? dim(` #${pane.session.sessionId.slice(0, 8)}`) : ""} ${dim("all input goes to focused pane")}` - } else { - gridFooter.content = t` ${dim("No sessions. Press ctrl+\` to return to picker.")}` - } +function resizeGridPanes() { + if (!directGrid || directGrid.paneCount === 0) return + directGrid.repositionAll() } async function handleGridInput(rawSequence: string): Promise { - if (viewMode !== "grid") return false + if (viewMode !== "grid" || !directGrid) return false + + // Esc — collapse expanded pane back to grid + if (rawSequence === "\x1b" && directGrid.isExpanded) { + directGrid.collapsePane() + return true + } // Ctrl+` (0x1e) or ESC+` — return to picker if (rawSequence === "\x1e" || rawSequence === "\x1b`") { @@ -889,40 +963,50 @@ async function handleGridInput(rawSequence: string): Promise { // Ctrl+N — focus next pane if (rawSequence === "\x0e") { - sessionGrid?.focusNext() - updateGridHeader() - updateGridFooter() + directGrid.focusNext() return true } // Ctrl+P — focus previous pane if (rawSequence === "\x10") { - sessionGrid?.focusPrev() - updateGridHeader() - updateGridFooter() + directGrid.focusPrev() + return true + } + + // Ctrl+F — open focused pane's project in Finder + if (rawSequence === "\x06") { + const pane = directGrid.focusedPane + if (pane) Bun.spawn(["open", pane.session.projectPath]) return true } // Ctrl+W — close focused pane if (rawSequence === "\x17") { - const pane = sessionGrid?.focusedPane + const pane = directGrid.focusedPane if (pane) { - const { killSession } = await import("./tmux/session-manager") - sessionGrid!.removeSession(pane.session.name) + if (directGrid.isExpanded) directGrid.collapsePane() + const { killSession } = await import("./pty/session-manager") + directGrid.removePane(pane.session.name) await killSession(pane.session.name) - updateGridHeader() - updateGridFooter() - if (sessionGrid!.paneCount === 0) { + if (directGrid.paneCount === 0) { switchToPicker() } } return true } - // Forward everything else to the focused tmux pane - if (sessionGrid) { - await sessionGrid.sendInputToFocused(rawSequence) + // PageUp / PageDown (Fn+Up/Down on Mac) — scroll focused pane + if (rawSequence === "\x1b[5~") { + directGrid.sendScrollToFocused("up") + return true } + if (rawSequence === "\x1b[6~") { + directGrid.sendScrollToFocused("down") + return true + } + + // Forward everything else to the focused PTY pane + directGrid.sendInputToFocused(rawSequence) return true } @@ -950,16 +1034,25 @@ async function main() { sortedIndices = projects.map((_, i) => i) rebuildDisplayRows() + // Save raw stdout.write BEFORE OpenTUI intercepts it + rawStdoutWrite = process.stdout.write.bind(process.stdout) as (s: string) => boolean + renderer = await createCliRenderer({ exitOnCtrlC: true, useAlternateScreen: true, - useMouse: true, + useMouse: false, // We handle mouse ourselves to avoid OpenTUI consuming events onDestroy: () => { destroyed = true if (monitorInterval) { clearInterval(monitorInterval); monitorInterval = null } + if (directGrid) directGrid.destroyAll() + stopAllCaptures() }, }) + // Enable mouse reporting manually (SGR mode for full coordinates) + process.stdout.write("\x1b[?1000h") // Basic click tracking + process.stdout.write("\x1b[?1006h") // SGR extended coordinates + // Build layout mainBox = new BoxRenderable(renderer, { flexDirection: "column", @@ -1055,15 +1148,273 @@ async function main() { updateUsagePanel() }).catch(() => {}) - // Intercept raw input for grid mode (before OpenTUI processes it) - renderer.prependInputHandler((sequence: string) => { - if (viewMode !== "grid") return false - // Handle grid input asynchronously, consume the event - handleGridInput(sequence) - return true + // Resize PTY panes when terminal window is resized + process.stdout.on("resize", () => { + if (viewMode !== "grid" || !directGrid) return + resizeGridPanes() }) - renderer.keyInput.on("keypress", handleKeypress) + // Shift+arrow sequences across terminal emulators + const SHIFT_ARROWS: Record = { + "\x1b[1;2A": "up", // xterm/iTerm2/most terminals + "\x1b[1;2B": "down", + "\x1b[1;2C": "right", + "\x1b[1;2D": "left", + "\x1b[a": "up", // rxvt + "\x1b[b": "down", + "\x1b[c": "right", + "\x1b[d": "left", + } + + // Take over stdin completely — removes OpenTUI's data listeners + // We parse all keys ourselves to avoid double-processing issues + process.stdin.removeAllListeners("data") + + // 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. + 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) + // Must consume the 3 payload bytes or they leak as keyboard input + // (btn+32 for left click = 0x20 = ASCII space!) + 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 + } + + // \x1b` (ctrl+backtick) — keep as keyboard shortcut + if (next === "`") { + keyboard += "\x1b`" + 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. Returns array of {btn, col, row, release, consumed}. + 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 + } + + function stdinHandler(data: string | Buffer) { + const str = typeof data === "string" ? data : data.toString("utf8") + + if (viewMode === "grid" && directGrid) { + // In select mode, only listen for Esc to exit — everything else is native terminal + if (directGrid.selectMode) { + const keyboard = extractKeyboardInput(str) + if (keyboard === "\x1b") { + directGrid.exitSelectMode() + } + return + } + + // Handle mouse events first (scroll wheel + click-to-focus) + const mouseEvents = extractMouseEvents(str) + for (const me of mouseEvents) { + // Scroll: btn 64 = scroll up, btn 65 = scroll down + if (me.btn === 64) { + directGrid.sendScrollToFocused("up", 3) + continue + } + if (me.btn === 65) { + directGrid.sendScrollToFocused("down", 3) + continue + } + // Click (btn 0, press only): check buttons first, then focus pane + if (me.btn === 0 && !me.release) { + const btn = directGrid.checkButtonClick(me.col, me.row) + if (btn?.action === "max") { + directGrid.expandPane(btn.paneIndex) + } else if (btn?.action === "min") { + directGrid.collapsePane() + } else if (btn?.action === "sel") { + directGrid.enterSelectMode() + } else { + directGrid.focusByClick(me.col, me.row) + } + continue + } + } + + // Strip mouse events from the data before keyboard filtering + let stripped = str + // Remove mouse events from end to start to preserve indices + 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) { + const dir = SHIFT_ARROWS[keyboard] + if (dir) { + directGrid.focusByDirection(dir) + } else { + handleGridInput(keyboard) + } + } + return + } + + // Picker mode: handle mouse clicks (scroll + click-to-select) + const pickerMouse = extractMouseEvents(str) + for (const me of pickerMouse) { + if (me.btn === 0 && !me.release) handlePickerClick(me.col, me.row) + if (me.btn === 64) { if (cursor > 0) { cursor--; updateAll() } } + if (me.btn === 65) { if (cursor < displayRows.length - 1) { cursor++; updateAll() } } + } + + // Parse keys directly (bypass OpenTUI pipeline to avoid double-firing) + const keyboard = extractKeyboardInput(str) + if (!keyboard) return + + // Map raw sequences to KeyEvent-like objects and call handleKeypress directly + const keyMap: Record = { + "\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" }, + } + + // Parse sequences from the keyboard string + let ki = 0 + while (ki < keyboard.length) { + let matched = false + // Try longest match first (up to 8 chars for CSI sequences) + for (let len = Math.min(8, keyboard.length - ki); len >= 1; len--) { + const seq = keyboard.slice(ki, ki + len) + const mapped = keyMap[seq] + if (mapped) { + const syntheticKey = { + name: mapped.name, + shift: mapped.shift || false, + ctrl: mapped.ctrl || false, + meta: false, + preventDefault: () => {}, + stopPropagation: () => {}, + } as KeyEvent + handleKeypress(syntheticKey) + ki += len + matched = true + break + } + } + if (!matched) { + // Single printable character + const ch = keyboard[ki] + const code = ch.charCodeAt(0) + // Map printable ASCII to key name + if (code >= 0x21 && code <= 0x7e) { + const syntheticKey = { + name: ch, + shift: false, + ctrl: false, + meta: false, + preventDefault: () => {}, + stopPropagation: () => {}, + } as KeyEvent + handleKeypress(syntheticKey) + } + ki++ + } + } + } + process.stdin.on("data", stdinHandler) // Live session monitoring if (demoMode) { @@ -1137,14 +1488,13 @@ async function main() { if (changed) updateAll() // Update grid pane statuses (flash idle sessions) - if (sessionGrid && viewMode === "grid") { + if (directGrid && viewMode === "grid") { await refreshAlive() for (const [, s] of getSessions()) { - // Use monitor.ts to check busy/idle const status = getSessionStatus(s.projectPath, s.sessionId) - if (status === "idle") sessionGrid.markIdle(s.name) - else if (status === "busy") sessionGrid.markBusy(s.name) - else sessionGrid.clearMark(s.name) + if (status === "idle") directGrid.markIdle(s.name) + else if (status === "busy") directGrid.markBusy(s.name) + else directGrid.clearMark(s.name) } } } diff --git a/src/pty/capture.ts b/src/pty/capture.ts new file mode 100644 index 0000000..7c3bdd4 --- /dev/null +++ b/src/pty/capture.ts @@ -0,0 +1,691 @@ +// 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 + reader: ReadableStreamDefaultReader | null + running: boolean +} + +const panes = new Map() + +// ─── 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 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 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() + +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) +} diff --git a/src/pty/session-manager.ts b/src/pty/session-manager.ts new file mode 100644 index 0000000..eeb106a --- /dev/null +++ b/src/pty/session-manager.ts @@ -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() +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 { + 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 { + 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) return + session.width = width + session.height = height + // Send APC resize command: \x1b_R;\x1b\\ + const resizeCmd = `\x1b_R${height};${width}\x1b\\` + session.proc.stdin.write(resizeCmd) +} + +export function writeToSession(name: string, data: string): void { + const session = sessions.get(name) + if (!session || !session.alive) return + session.proc.stdin.write(data) +} + +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}` +} diff --git a/src/tmux/capture.ts b/src/tmux/capture.ts index 9134031..8f74fe0 100644 --- a/src/tmux/capture.ts +++ b/src/tmux/capture.ts @@ -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 { - 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" }), - ] +const SEP = "%%CLADM_FRAME%%" - const [contentText, infoText] = await Promise.all([ - new Response(contentProc.stdout).text(), - new Response(infoProc.stdout).text(), - ]) +type FrameCallback = (frame: CaptureResult) => void - const [codeContent, codeInfo] = await Promise.all([contentProc.exited, infoProc.exited]) - if (codeContent !== 0 || codeInfo !== 0) return null +interface PaneCapture { + proc: Subprocess<"ignore", "pipe", "ignore"> + latest: CaptureResult | null + buf: string + callbacks: Set +} - const parts = infoText.trim().split(" ") +const panes = new Map() + +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 { + while (true) { + const { done, value } = await reader.read() + if (done) break + state.buf += decoder.decode(value, { stream: true }) + processBuffer(sessionName, state) + } + } catch { + // Process died + } + })() +} + +function processBuffer(sessionName: string, state: PaneCapture) { + while (true) { + const endMarker = SEP + "END" + const endIdx = state.buf.indexOf(endMarker) + if (endIdx < 0) break + + 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 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 { + 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() -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) } diff --git a/src/tmux/input-bridge.ts b/src/tmux/input-bridge.ts index 01d912f..9b23eb9 100644 --- a/src/tmux/input-bridge.ts +++ b/src/tmux/input-bridge.ts @@ -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 { - // 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 = { "\r": "Enter", "\n": "Enter", diff --git a/src/tmux/session-manager.ts b/src/tmux/session-manager.ts index ad39888..3d33964 100644 --- a/src/tmux/session-manager.ts +++ b/src/tmux/session-manager.ts @@ -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 }