feat: add direct PTY grid with expand/select mode for text copying
Replace tmux-based grid with direct PTY rendering. Add [MAX] button to expand a pane to fullscreen and [SEL] to enable native text selection within the expanded pane. Esc exits select/expanded mode. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
5
bun.lock
5
bun.lock
@@ -6,6 +6,7 @@
|
|||||||
"name": "tui-claude-director",
|
"name": "tui-claude-director",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentui/core": "^0.1.81",
|
"@opentui/core": "^0.1.81",
|
||||||
|
"node-pty": "^1.1.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
@@ -146,6 +147,10 @@
|
|||||||
|
|
||||||
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
"mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||||
|
|
||||||
|
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
||||||
|
|
||||||
|
"node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="],
|
||||||
|
|
||||||
"omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="],
|
"omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="],
|
||||||
|
|
||||||
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentui/core": "^0.1.81"
|
"@opentui/core": "^0.1.81",
|
||||||
|
"node-pty": "^1.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Project } from "../lib/types"
|
import type { Project } from "../lib/types"
|
||||||
|
import { loadSessions } from "../data/sessions"
|
||||||
|
|
||||||
interface LaunchItem {
|
interface LaunchItem {
|
||||||
path: string
|
path: string
|
||||||
@@ -19,7 +20,16 @@ export async function launchSelections(
|
|||||||
const targetBranch = selectedBranches.get(path)
|
const targetBranch = selectedBranches.get(path)
|
||||||
const project = projects.find(p => p.path === path)
|
const project = projects.find(p => p.path === path)
|
||||||
const needsBranch = targetBranch && project && targetBranch !== project.branch
|
const needsBranch = targetBranch && project && targetBranch !== project.branch
|
||||||
byProject.get(path)!.push({ path, targetBranch: needsBranch ? targetBranch : undefined })
|
// Auto-resume most recent session
|
||||||
|
let lastSessionId: string | undefined
|
||||||
|
if (project) {
|
||||||
|
if (!project.sessions) {
|
||||||
|
project.sessions = await loadSessions(project.path)
|
||||||
|
project.sessionCount = project.sessions.length
|
||||||
|
}
|
||||||
|
lastSessionId = project.sessions[0]?.id
|
||||||
|
}
|
||||||
|
byProject.get(path)!.push({ path, sessionId: lastSessionId, targetBranch: needsBranch ? targetBranch : undefined })
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
|
|||||||
538
src/components/direct-grid.ts
Normal file
538
src/components/direct-grid.ts
Normal file
@@ -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<string, ReturnType<typeof setInterval>>()
|
||||||
|
private titleTimer: ReturnType<typeof setInterval> | 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<GridPaneInfo> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
74
src/components/direct-pane.ts
Normal file
74
src/components/direct-pane.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
// Lightweight terminal pane: writes raw ANSI from PTY capture directly to stdout.
|
||||||
|
// No parsing, no FrameBuffer, no OpenTUI. Just cursor-addressed raw lines.
|
||||||
|
|
||||||
|
import { onFrame, hasChanged, resetHash, type CaptureResult } from "../pty/capture"
|
||||||
|
|
||||||
|
export class DirectPane {
|
||||||
|
screenX: number // 1-based screen column of content area
|
||||||
|
screenY: number // 1-based screen row of content area
|
||||||
|
width: number // content columns
|
||||||
|
height: number // content rows
|
||||||
|
sessionName = ""
|
||||||
|
|
||||||
|
private unsub: (() => void) | null = null
|
||||||
|
|
||||||
|
// Set by DirectGridRenderer to receive frame updates
|
||||||
|
onFrame: ((lines: string[], pane: DirectPane) => void) | null = null
|
||||||
|
|
||||||
|
constructor(x: number, y: number, w: number, h: number) {
|
||||||
|
this.screenX = x
|
||||||
|
this.screenY = y
|
||||||
|
this.width = w
|
||||||
|
this.height = h
|
||||||
|
}
|
||||||
|
|
||||||
|
attach(sessionName: string) {
|
||||||
|
this.detach()
|
||||||
|
this.sessionName = sessionName
|
||||||
|
resetHash(`dp_${sessionName}`)
|
||||||
|
this.unsub = onFrame(sessionName, (result) => {
|
||||||
|
if (!hasChanged(result.lines, `dp_${sessionName}`)) return
|
||||||
|
if (this.onFrame) this.onFrame(result.lines, this)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
detach() {
|
||||||
|
if (this.unsub) { this.unsub(); this.unsub = null }
|
||||||
|
if (this.sessionName) resetHash(`dp_${this.sessionName}`)
|
||||||
|
this.sessionName = ""
|
||||||
|
this.onFrame = null
|
||||||
|
}
|
||||||
|
|
||||||
|
reposition(x: number, y: number, w: number, h: number) {
|
||||||
|
this.screenX = x
|
||||||
|
this.screenY = y
|
||||||
|
this.width = w
|
||||||
|
this.height = h
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build cursor-addressed ANSI output for this pane's content.
|
||||||
|
// Returns a string ready for stdout.write(). No allocations beyond the string.
|
||||||
|
buildFrame(lines: string[]): string {
|
||||||
|
let out = ""
|
||||||
|
const x = this.screenX
|
||||||
|
const y = this.screenY
|
||||||
|
const w = this.width
|
||||||
|
const h = this.height
|
||||||
|
|
||||||
|
for (let row = 0; row < h; row++) {
|
||||||
|
// Position cursor at start of this row
|
||||||
|
out += `\x1b[${y + row};${x}H`
|
||||||
|
// Erase w characters (clears old content)
|
||||||
|
out += `\x1b[${w}X`
|
||||||
|
|
||||||
|
if (row < lines.length) {
|
||||||
|
// Write raw ANSI line from PTY (already correct width)
|
||||||
|
out += lines[row]
|
||||||
|
// Reset SGR to prevent color bleed into border
|
||||||
|
out += "\x1b[0m"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,13 +10,31 @@ import {
|
|||||||
} from "@opentui/core"
|
} from "@opentui/core"
|
||||||
import { TerminalView, getProjectColor } from "./terminal-view"
|
import { TerminalView, getProjectColor } from "./terminal-view"
|
||||||
import type { TmuxSession } from "../tmux/session-manager"
|
import type { TmuxSession } from "../tmux/session-manager"
|
||||||
import { sendKeys } from "../tmux/input-bridge"
|
import { sendKeys, sendMouseEvent } from "../tmux/input-bridge"
|
||||||
|
|
||||||
|
export type PaneStatus = "busy" | "idle" | null
|
||||||
|
|
||||||
export interface GridPane {
|
export interface GridPane {
|
||||||
session: TmuxSession
|
session: TmuxSession
|
||||||
termView: TerminalView
|
termView: TerminalView
|
||||||
borderBox: BoxRenderable
|
borderBox: BoxRenderable
|
||||||
titleText: TextRenderable
|
titleText: TextRenderable
|
||||||
|
subtitleText: TextRenderable
|
||||||
|
status: PaneStatus
|
||||||
|
statusSince: number // Date.now() when status was set
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtElapsed(sinceMs: number): string {
|
||||||
|
if (!sinceMs) return ""
|
||||||
|
const sec = Math.floor((Date.now() - sinceMs) / 1000)
|
||||||
|
if (sec < 1) return "0s"
|
||||||
|
if (sec < 60) return `${sec}s`
|
||||||
|
const m = Math.floor(sec / 60)
|
||||||
|
const s = sec % 60
|
||||||
|
if (m < 60) return `${m}m${s > 0 ? String(s).padStart(2, "0") + "s" : ""}`
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
const rm = m % 60
|
||||||
|
return `${h}h${rm > 0 ? String(rm).padStart(2, "0") + "m" : ""}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SessionGrid {
|
export class SessionGrid {
|
||||||
@@ -25,17 +43,19 @@ export class SessionGrid {
|
|||||||
private panes: GridPane[] = []
|
private panes: GridPane[] = []
|
||||||
private _focusIndex = 0
|
private _focusIndex = 0
|
||||||
private flashTimers = new Map<string, ReturnType<typeof setInterval>>()
|
private flashTimers = new Map<string, ReturnType<typeof setInterval>>()
|
||||||
|
private titleTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
constructor(renderer: CliRenderer, container: BoxRenderable) {
|
constructor(renderer: CliRenderer, container: BoxRenderable) {
|
||||||
this.renderer = renderer
|
this.renderer = renderer
|
||||||
this.container = container
|
this.container = container
|
||||||
|
this.titleTimer = setInterval(() => this.refreshTitles(), 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
get focusIndex() { return this._focusIndex }
|
get focusIndex() { return this._focusIndex }
|
||||||
get paneCount() { return this.panes.length }
|
get paneCount() { return this.panes.length }
|
||||||
get focusedPane(): GridPane | null { return this.panes[this._focusIndex] ?? null }
|
get focusedPane(): GridPane | null { return this.panes[this._focusIndex] ?? null }
|
||||||
|
|
||||||
addSession(session: TmuxSession): GridPane {
|
addSession(session: TmuxSession, subtitle?: ReturnType<typeof t>): GridPane {
|
||||||
const color = getProjectColor(session.colorIndex)
|
const color = getProjectColor(session.colorIndex)
|
||||||
const colorRGBA = RGBA.fromHex(color)
|
const colorRGBA = RGBA.fromHex(color)
|
||||||
|
|
||||||
@@ -55,18 +75,27 @@ export class SessionGrid {
|
|||||||
})
|
})
|
||||||
titleText.content = t` ${bold(fg(color)(session.projectName))}${session.sessionId ? dim(` #${session.sessionId.slice(0, 8)}`) : ""}`
|
titleText.content = t` ${bold(fg(color)(session.projectName))}${session.sessionId ? dim(` #${session.sessionId.slice(0, 8)}`) : ""}`
|
||||||
|
|
||||||
// Calculate pane size (leave room for border + title)
|
const subtitleText = new TextRenderable(this.renderer, {
|
||||||
|
width: "100%",
|
||||||
|
height: 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
})
|
||||||
|
if (subtitle) subtitleText.content = subtitle
|
||||||
|
else subtitleText.content = t` ${dim("...")}`
|
||||||
|
|
||||||
|
// Calculate pane size (leave room for border + title + subtitle)
|
||||||
const dims = this.calcPaneDims()
|
const dims = this.calcPaneDims()
|
||||||
const termView = new TerminalView(this.renderer, {
|
const termView = new TerminalView(this.renderer, {
|
||||||
width: Math.max(dims.w - 2, 10),
|
width: Math.max(dims.w - 2, 10),
|
||||||
height: Math.max(dims.h - 3, 4),
|
height: Math.max(dims.h - 4, 4),
|
||||||
})
|
})
|
||||||
|
|
||||||
borderBox.add(titleText)
|
borderBox.add(titleText)
|
||||||
|
borderBox.add(subtitleText)
|
||||||
borderBox.add(termView)
|
borderBox.add(termView)
|
||||||
this.container.add(borderBox)
|
this.container.add(borderBox)
|
||||||
|
|
||||||
const pane: GridPane = { session, termView, borderBox, titleText }
|
const pane: GridPane = { session, termView, borderBox, titleText, subtitleText, status: null, statusSince: 0 }
|
||||||
this.panes.push(pane)
|
this.panes.push(pane)
|
||||||
|
|
||||||
termView.attach(session)
|
termView.attach(session)
|
||||||
@@ -114,10 +143,110 @@ export class SessionGrid {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendInputToFocused(rawSequence: string) {
|
flashFocused() {
|
||||||
const pane = this.focusedPane
|
const pane = this.focusedPane
|
||||||
if (!pane) return
|
if (!pane) return
|
||||||
await sendKeys(pane.session.name, rawSequence)
|
const flashColor = RGBA.fromHex("#7dcfff")
|
||||||
|
pane.borderBox.borderColor = flashColor
|
||||||
|
this.renderer.requestRender()
|
||||||
|
setTimeout(() => {
|
||||||
|
// Restore to heavy white (focused state)
|
||||||
|
pane.borderBox.borderColor = RGBA.fromHex("#ffffff")
|
||||||
|
this.renderer.requestRender()
|
||||||
|
}, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
focusByDirection(dir: "up" | "down" | "left" | "right") {
|
||||||
|
const n = this.panes.length
|
||||||
|
if (n <= 1) return
|
||||||
|
const cols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4
|
||||||
|
const rows = Math.ceil(n / cols)
|
||||||
|
const curCol = this._focusIndex % cols
|
||||||
|
const curRow = Math.floor(this._focusIndex / cols)
|
||||||
|
|
||||||
|
let newCol = curCol
|
||||||
|
let newRow = curRow
|
||||||
|
switch (dir) {
|
||||||
|
case "left": newCol = (curCol - 1 + cols) % cols; break
|
||||||
|
case "right": newCol = (curCol + 1) % cols; break
|
||||||
|
case "up": newRow = (curRow - 1 + rows) % rows; break
|
||||||
|
case "down": newRow = (curRow + 1) % rows; break
|
||||||
|
}
|
||||||
|
const idx = newRow * cols + newCol
|
||||||
|
if (idx >= 0 && idx < n) {
|
||||||
|
this._focusIndex = idx
|
||||||
|
this.updateBorders()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focusByClick(col: number, row: number): boolean {
|
||||||
|
const n = this.panes.length
|
||||||
|
if (n === 0) return false
|
||||||
|
const termW = process.stdout.columns || 120
|
||||||
|
const termH = process.stdout.rows || 40
|
||||||
|
const cols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4
|
||||||
|
const rows = Math.ceil(n / cols)
|
||||||
|
const cellW = Math.floor(termW / cols)
|
||||||
|
const cellH = Math.floor((termH - 2) / rows) // -2 for header+footer
|
||||||
|
const gridCol = Math.floor(col / cellW)
|
||||||
|
const gridRow = Math.floor((row - 1) / cellH) // -1 for header line
|
||||||
|
const idx = gridRow * cols + gridCol
|
||||||
|
if (idx >= 0 && idx < n) {
|
||||||
|
this._focusIndex = idx
|
||||||
|
this.updateBorders()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
sendInputToFocused(rawSequence: string) {
|
||||||
|
const pane = this.focusedPane
|
||||||
|
if (!pane) return
|
||||||
|
sendKeys(pane.session.name, rawSequence)
|
||||||
|
pane.termView.nudge()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hit-test: map absolute screen coords to pane index + relative terminal coords
|
||||||
|
hitTest(absCol: number, absRow: number): { index: number, relX: number, relY: number } | null {
|
||||||
|
const n = this.panes.length
|
||||||
|
if (n === 0) return null
|
||||||
|
const termW = process.stdout.columns || 120
|
||||||
|
const termH = process.stdout.rows || 40
|
||||||
|
const gridCols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4
|
||||||
|
const gridRows = Math.ceil(n / gridCols)
|
||||||
|
const cellW = Math.floor(termW / gridCols)
|
||||||
|
const cellH = Math.floor((termH - 2) / gridRows)
|
||||||
|
|
||||||
|
// Convert 1-based screen coords to grid cell
|
||||||
|
const gc = Math.floor((absCol - 1) / cellW)
|
||||||
|
const gr = Math.floor((absRow - 2) / cellH) // row 2 is first grid row (row 1 = header)
|
||||||
|
if (gc < 0 || gc >= gridCols || gr < 0 || gr >= gridRows) return null
|
||||||
|
|
||||||
|
const idx = gr * gridCols + gc
|
||||||
|
if (idx < 0 || idx >= n) return null
|
||||||
|
|
||||||
|
// Terminal content within cell: after left border(1) + after top border(1)+title(1)+subtitle(1)
|
||||||
|
const termStartX = gc * cellW + 2 // 1-based + left border
|
||||||
|
const termStartY = 2 + gr * cellH + 3 // header(1) + top border(1) + title(1) + subtitle(1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
index: idx,
|
||||||
|
relX: absCol - termStartX + 1, // 1-based for tmux
|
||||||
|
relY: absRow - termStartY + 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward mouse event to the focused tmux pane with correct relative coordinates
|
||||||
|
sendMouseToFocused(absCol: number, absRow: number, btn: number, release: boolean) {
|
||||||
|
const pane = this.focusedPane
|
||||||
|
if (!pane) return
|
||||||
|
|
||||||
|
const hit = this.hitTest(absCol, absRow)
|
||||||
|
if (!hit || hit.index !== this._focusIndex) return
|
||||||
|
if (hit.relX < 1 || hit.relY < 1) return
|
||||||
|
if (hit.relX > pane.session.width || hit.relY > pane.session.height) return
|
||||||
|
|
||||||
|
sendMouseEvent(pane.session.name, hit.relX, hit.relY, btn, release)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flash a pane's border to draw attention (e.g., when session goes idle)
|
// Flash a pane's border to draw attention (e.g., when session goes idle)
|
||||||
@@ -159,34 +288,69 @@ export class SessionGrid {
|
|||||||
markIdle(sessionName: string) {
|
markIdle(sessionName: string) {
|
||||||
const pane = this.panes.find(p => p.session.name === sessionName)
|
const pane = this.panes.find(p => p.session.name === sessionName)
|
||||||
if (!pane) return
|
if (!pane) return
|
||||||
|
if (pane.status !== "idle") {
|
||||||
|
pane.status = "idle"
|
||||||
|
pane.statusSince = Date.now()
|
||||||
|
}
|
||||||
this.startFlash(sessionName)
|
this.startFlash(sessionName)
|
||||||
pane.titleText.content = t` ${bold(fg(getProjectColor(pane.session.colorIndex))(pane.session.projectName))} ${fg("#e0af68")("NEEDS INPUT")}`
|
this.renderPaneTitle(pane)
|
||||||
this.renderer.requestRender()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
markBusy(sessionName: string) {
|
markBusy(sessionName: string) {
|
||||||
const pane = this.panes.find(p => p.session.name === sessionName)
|
const pane = this.panes.find(p => p.session.name === sessionName)
|
||||||
if (!pane) return
|
if (!pane) return
|
||||||
|
if (pane.status !== "busy") {
|
||||||
|
pane.status = "busy"
|
||||||
|
pane.statusSince = Date.now()
|
||||||
|
}
|
||||||
this.clearFlash(sessionName)
|
this.clearFlash(sessionName)
|
||||||
pane.titleText.content = t` ${bold(fg(getProjectColor(pane.session.colorIndex))(pane.session.projectName))} ${fg("#9ece6a")("RUNNING")}`
|
this.renderPaneTitle(pane)
|
||||||
this.renderer.requestRender()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearMark(sessionName: string) {
|
clearMark(sessionName: string) {
|
||||||
const pane = this.panes.find(p => p.session.name === sessionName)
|
const pane = this.panes.find(p => p.session.name === sessionName)
|
||||||
if (!pane) return
|
if (!pane) return
|
||||||
|
pane.status = null
|
||||||
|
pane.statusSince = 0
|
||||||
this.clearFlash(sessionName)
|
this.clearFlash(sessionName)
|
||||||
|
this.renderPaneTitle(pane)
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderPaneTitle(pane: GridPane) {
|
||||||
const color = getProjectColor(pane.session.colorIndex)
|
const color = getProjectColor(pane.session.colorIndex)
|
||||||
pane.titleText.content = t` ${bold(fg(color)(pane.session.projectName))}${pane.session.sessionId ? dim(` #${pane.session.sessionId.slice(0, 8)}`) : ""}`
|
const name = bold(fg(color)(pane.session.projectName))
|
||||||
|
const elapsed = pane.statusSince ? fmtElapsed(pane.statusSince) : ""
|
||||||
|
|
||||||
|
if (pane.status === "idle") {
|
||||||
|
pane.titleText.content = t` ${name} ${fg("#e0af68")("IDLE")} ${dim(elapsed)}`
|
||||||
|
} else if (pane.status === "busy") {
|
||||||
|
pane.titleText.content = t` ${name} ${fg("#9ece6a")("RUNNING")} ${dim(elapsed)}`
|
||||||
|
} else {
|
||||||
|
pane.titleText.content = t` ${name}${pane.session.sessionId ? dim(` #${pane.session.sessionId.slice(0, 8)}`) : ""}`
|
||||||
|
}
|
||||||
this.renderer.requestRender()
|
this.renderer.requestRender()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private refreshTitles() {
|
||||||
|
let needsRender = false
|
||||||
|
for (const pane of this.panes) {
|
||||||
|
if (pane.status && pane.statusSince) {
|
||||||
|
this.renderPaneTitle(pane)
|
||||||
|
needsRender = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (needsRender) this.renderer.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
private updateBorders() {
|
private updateBorders() {
|
||||||
for (let i = 0; i < this.panes.length; i++) {
|
for (let i = 0; i < this.panes.length; i++) {
|
||||||
const pane = this.panes[i]
|
const pane = this.panes[i]
|
||||||
const isFocused = i === this._focusIndex
|
const isFocused = i === this._focusIndex
|
||||||
const color = getProjectColor(pane.session.colorIndex)
|
const color = getProjectColor(pane.session.colorIndex)
|
||||||
|
|
||||||
|
// Update terminal view focus state (controls poll rate)
|
||||||
|
pane.termView.focused = isFocused
|
||||||
|
|
||||||
// Focused pane gets brighter border, others get dimmer
|
// Focused pane gets brighter border, others get dimmer
|
||||||
if (isFocused) {
|
if (isFocused) {
|
||||||
pane.borderBox.borderColor = RGBA.fromHex("#ffffff")
|
pane.borderBox.borderColor = RGBA.fromHex("#ffffff")
|
||||||
@@ -241,6 +405,7 @@ export class SessionGrid {
|
|||||||
}
|
}
|
||||||
|
|
||||||
destroyAll() {
|
destroyAll() {
|
||||||
|
if (this.titleTimer) { clearInterval(this.titleTimer); this.titleTimer = null }
|
||||||
for (const timer of this.flashTimers.values()) clearInterval(timer)
|
for (const timer of this.flashTimers.values()) clearInterval(timer)
|
||||||
this.flashTimers.clear()
|
this.flashTimers.clear()
|
||||||
for (const pane of this.panes) {
|
for (const pane of this.panes) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
RGBA,
|
RGBA,
|
||||||
TextAttributes,
|
TextAttributes,
|
||||||
} from "@opentui/core"
|
} from "@opentui/core"
|
||||||
import { capturePane, hasChanged, resetHash } from "../tmux/capture"
|
import { startCapture, stopCapture, setCaptureRate, onFrame, hasChanged, resetHash, type CaptureResult } from "../tmux/capture"
|
||||||
import { parseAnsiFrame, type ParsedFrame } from "../tmux/ansi-parser"
|
import { parseAnsiFrame, type ParsedFrame } from "../tmux/ansi-parser"
|
||||||
import type { TmuxSession } from "../tmux/session-manager"
|
import type { TmuxSession } from "../tmux/session-manager"
|
||||||
|
|
||||||
@@ -28,34 +28,52 @@ export function getProjectColor(colorIndex: number): string {
|
|||||||
return PROJECT_COLORS[colorIndex % PROJECT_COLORS.length]
|
return PROJECT_COLORS[colorIndex % PROJECT_COLORS.length]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push-based terminal view: no poll timers.
|
||||||
|
// Subscribes to capture stream and renders only when content changes.
|
||||||
export class TerminalView extends FrameBufferRenderable {
|
export class TerminalView extends FrameBufferRenderable {
|
||||||
session: TmuxSession | null = null
|
session: TmuxSession | null = null
|
||||||
private pollTimer: ReturnType<typeof setInterval> | null = null
|
private unsubCapture: (() => void) | null = null
|
||||||
private lastFrame: ParsedFrame | null = null
|
private lastFrame: ParsedFrame | null = null
|
||||||
private _focused = false
|
private _focused = false
|
||||||
private _flashUntil = 0 // timestamp until which border flashes
|
private _flashUntil = 0
|
||||||
private _idleSince = 0
|
private _idleSince = 0
|
||||||
|
private _frameDirty = false
|
||||||
|
|
||||||
constructor(ctx: RenderContext, options: FrameBufferOptions) {
|
constructor(ctx: RenderContext, options: FrameBufferOptions) {
|
||||||
super(ctx, options)
|
super(ctx, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
get focused() { return this._focused }
|
get focused() { return this._focused }
|
||||||
set focused(v: boolean) { this._focused = v }
|
set focused(v: boolean) {
|
||||||
|
if (this._focused === v) return
|
||||||
|
this._focused = v
|
||||||
|
// Focused pane captures at ~60fps, unfocused at ~5fps
|
||||||
|
if (this.session) {
|
||||||
|
setCaptureRate(this.session.name, v ? 16 : 200)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get idleSince() { return this._idleSince }
|
get idleSince() { return this._idleSince }
|
||||||
|
|
||||||
attach(session: TmuxSession) {
|
attach(session: TmuxSession) {
|
||||||
this.detach()
|
this.detach()
|
||||||
this.session = session
|
this.session = session
|
||||||
resetHash()
|
resetHash(session.name)
|
||||||
this.startPolling()
|
const captureMs = this._focused ? 16 : 200
|
||||||
|
startCapture(session.name, captureMs)
|
||||||
|
// Subscribe to push notifications — no poll timer needed
|
||||||
|
this.unsubCapture = onFrame(session.name, (frame) => this.onNewFrame(frame))
|
||||||
}
|
}
|
||||||
|
|
||||||
detach() {
|
detach() {
|
||||||
this.stopPolling()
|
if (this.unsubCapture) { this.unsubCapture(); this.unsubCapture = null }
|
||||||
|
if (this.session) {
|
||||||
|
stopCapture(this.session.name)
|
||||||
|
resetHash(this.session.name)
|
||||||
|
}
|
||||||
this.session = null
|
this.session = null
|
||||||
this.lastFrame = null
|
this.lastFrame = null
|
||||||
|
this._frameDirty = false
|
||||||
}
|
}
|
||||||
|
|
||||||
flash(durationMs = 2000) {
|
flash(durationMs = 2000) {
|
||||||
@@ -70,29 +88,19 @@ export class TerminalView extends FrameBufferRenderable {
|
|||||||
this._idleSince = 0
|
this._idleSince = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private startPolling() {
|
// Hint that input was sent — request immediate render
|
||||||
if (this.pollTimer) return
|
nudge() {
|
||||||
this.pollTimer = setInterval(() => this.refresh(), 80)
|
this.requestRender()
|
||||||
}
|
}
|
||||||
|
|
||||||
private stopPolling() {
|
private onNewFrame(result: CaptureResult) {
|
||||||
if (this.pollTimer) {
|
|
||||||
clearInterval(this.pollTimer)
|
|
||||||
this.pollTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async refresh() {
|
|
||||||
if (!this.session) return
|
if (!this.session) return
|
||||||
|
if (!hasChanged(result.lines, this.session.name)) return
|
||||||
const result = await capturePane(this.session.name)
|
|
||||||
if (!result) return
|
|
||||||
|
|
||||||
if (!hasChanged(result.lines)) return
|
|
||||||
|
|
||||||
const frame = parseAnsiFrame(result.lines, result.width, result.height)
|
const frame = parseAnsiFrame(result.lines, result.width, result.height)
|
||||||
this.lastFrame = frame
|
this.lastFrame = frame
|
||||||
this.renderFrameToBuffer(frame)
|
this._frameDirty = true
|
||||||
|
this.requestRender()
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderFrameToBuffer(frame: ParsedFrame) {
|
private renderFrameToBuffer(frame: ParsedFrame) {
|
||||||
@@ -113,17 +121,20 @@ export class TerminalView extends FrameBufferRenderable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only write to framebuffer when content actually changed (dirty flag)
|
||||||
|
// Previously this re-rendered EVERY paint cycle — major CPU waste
|
||||||
protected renderSelf(buffer: OptimizedBuffer) {
|
protected renderSelf(buffer: OptimizedBuffer) {
|
||||||
if (this.lastFrame) {
|
if (this._frameDirty && this.lastFrame) {
|
||||||
this.renderFrameToBuffer(this.lastFrame)
|
this.renderFrameToBuffer(this.lastFrame)
|
||||||
|
this._frameDirty = false
|
||||||
}
|
}
|
||||||
super.renderSelf(buffer)
|
super.renderSelf(buffer)
|
||||||
}
|
}
|
||||||
|
|
||||||
protected onResize(width: number, height: number) {
|
protected onResize(width: number, height: number) {
|
||||||
super.onResize(width, height)
|
super.onResize(width, height)
|
||||||
|
this._frameDirty = true // Re-render frame to new buffer size
|
||||||
if (this.session) {
|
if (this.session) {
|
||||||
// Resize tmux pane to match (async, fire-and-forget)
|
|
||||||
import("../tmux/session-manager").then(m => {
|
import("../tmux/session-manager").then(m => {
|
||||||
if (this.session) m.resizePane(this.session.name, width, height)
|
if (this.session) m.resizePane(this.session.name, width, height)
|
||||||
})
|
})
|
||||||
|
|||||||
576
src/index.ts
576
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 { 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 { getUsageSummary, formatCost, formatWindow, makeBar, pct, PLAN_LIMITS, type UsageSummary } from "./data/usage"
|
||||||
import { launchSelections } from "./actions/launcher"
|
import { launchSelections } from "./actions/launcher"
|
||||||
import { createSession, getSessions, refreshAlive, type TmuxSession } from "./tmux/session-manager"
|
import { createSession, getSessions, refreshAlive, type PtySession } from "./pty/session-manager"
|
||||||
import { SessionGrid } from "./components/session-grid"
|
import { stopAllCaptures } from "./pty/capture"
|
||||||
import { getProjectColor } from "./components/terminal-view"
|
import { DirectGridRenderer } from "./components/direct-grid"
|
||||||
import type { Project, DisplayRow } from "./lib/types"
|
import type { Project, DisplayRow } from "./lib/types"
|
||||||
import { timeAgo, formatSize, elapsedCompact } from "./lib/time"
|
import { timeAgo, formatSize, elapsedCompact } from "./lib/time"
|
||||||
|
|
||||||
@@ -57,11 +57,9 @@ let cachedIdleSessions: import("./data/monitor").IdleSessionInfo[] = []
|
|||||||
// ─── Grid Mode State ───────────────────────────────────────────────
|
// ─── Grid Mode State ───────────────────────────────────────────────
|
||||||
type ViewMode = "picker" | "grid"
|
type ViewMode = "picker" | "grid"
|
||||||
let viewMode: ViewMode = "picker"
|
let viewMode: ViewMode = "picker"
|
||||||
let sessionGrid: SessionGrid | null = null
|
let directGrid: DirectGridRenderer | null = null
|
||||||
let gridContainer: BoxRenderable | null = null
|
|
||||||
let gridHeader: TextRenderable | null = null
|
|
||||||
let gridFooter: TextRenderable | null = null
|
|
||||||
let mainBox: BoxRenderable | null = null
|
let mainBox: BoxRenderable | null = null
|
||||||
|
let rawStdoutWrite: (s: string) => boolean
|
||||||
|
|
||||||
// ─── UI Refs ────────────────────────────────────────────────────────
|
// ─── UI Refs ────────────────────────────────────────────────────────
|
||||||
let renderer: CliRenderer
|
let renderer: CliRenderer
|
||||||
@@ -384,7 +382,7 @@ function updateFooter() {
|
|||||||
)}`
|
)}`
|
||||||
} else {
|
} else {
|
||||||
footerText.content = t` ${dim(
|
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()
|
updateFooter()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Mouse Parsing ──────────────────────────────────────────────────
|
||||||
|
interface MouseEvent { btn: number; col: number; row: number; release: boolean }
|
||||||
|
|
||||||
|
function parseMouseEvent(seq: string): MouseEvent | null {
|
||||||
|
// SGR encoding: \x1b[<btn;col;rowM (press) or \x1b[<btn;col;rowm (release)
|
||||||
|
const sgr = seq.match(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/)
|
||||||
|
if (sgr) {
|
||||||
|
return {
|
||||||
|
btn: parseInt(sgr[1]),
|
||||||
|
col: parseInt(sgr[2]),
|
||||||
|
row: parseInt(sgr[3]),
|
||||||
|
release: sgr[4] === "m",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy X10/normal encoding: \x1b[M followed by 3 bytes (btn+32, col+32, row+32)
|
||||||
|
if (seq.length >= 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 ───────────────────────────────────────────────────────
|
// ─── Keyboard ───────────────────────────────────────────────────────
|
||||||
async function handleKeypress(key: KeyEvent) {
|
async function handleKeypress(key: KeyEvent) {
|
||||||
try {
|
try {
|
||||||
@@ -597,7 +671,6 @@ async function handleKeypress(key: KeyEvent) {
|
|||||||
selectedBranches.set(path, row.branchName!)
|
selectedBranches.set(path, row.branchName!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cursor < total - 1) cursor++
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -676,16 +749,36 @@ async function handleKeypress(key: KeyEvent) {
|
|||||||
break
|
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 "q":
|
||||||
case "escape":
|
case "escape":
|
||||||
destroyed = true
|
destroyed = true
|
||||||
if (monitorInterval) clearInterval(monitorInterval)
|
if (monitorInterval) clearInterval(monitorInterval)
|
||||||
|
stopAllCaptures()
|
||||||
|
process.stdout.write("\x1b[?1006l")
|
||||||
|
process.stdout.write("\x1b[?1000l")
|
||||||
renderer.destroy()
|
renderer.destroy()
|
||||||
return
|
return
|
||||||
|
|
||||||
case "t":
|
case "t":
|
||||||
// Switch to grid view if there are tmux sessions
|
// Switch to grid view if there are panes
|
||||||
if (sessionGrid && sessionGrid.paneCount > 0) {
|
if (directGrid && directGrid.paneCount > 0) {
|
||||||
switchToGrid()
|
switchToGrid()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -751,7 +844,13 @@ async function doLaunch() {
|
|||||||
if (!project) continue
|
if (!project) continue
|
||||||
const targetBranch = selectedBranches.get(path)
|
const targetBranch = selectedBranches.get(path)
|
||||||
const needsBranch = targetBranch && targetBranch !== project.branch
|
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) {
|
for (const project of projects) {
|
||||||
@@ -767,16 +866,17 @@ async function doLaunch() {
|
|||||||
|
|
||||||
if (items.length === 0) return
|
if (items.length === 0) return
|
||||||
|
|
||||||
// Create tmux sessions and switch to grid view
|
// Create PTY sessions and switch to grid view
|
||||||
ensureGridView()
|
ensureGridView()
|
||||||
|
|
||||||
|
// DirectGridRenderer calculates pane sizes internally
|
||||||
const termW = process.stdout.columns || 120
|
const termW = process.stdout.columns || 120
|
||||||
const termH = process.stdout.rows || 40
|
const termH = process.stdout.rows || 40
|
||||||
const n = items.length + (sessionGrid?.paneCount || 0)
|
const totalPanes = items.length + (directGrid?.paneCount || 0)
|
||||||
const cols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : 3
|
const cols = totalPanes <= 1 ? 1 : totalPanes <= 2 ? 2 : totalPanes <= 4 ? 2 : 3
|
||||||
const rows = Math.ceil(n / cols)
|
const rows = Math.ceil(totalPanes / cols)
|
||||||
const paneW = Math.floor(termW / cols) - 2
|
const paneW = Math.max(Math.floor(termW / cols) - 2, 20)
|
||||||
const paneH = Math.floor((termH - 4) / rows) - 3
|
const paneH = Math.max(Math.floor((termH - 2) / rows) - 4, 6)
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const session = await createSession({
|
const session = await createSession({
|
||||||
@@ -784,102 +884,76 @@ async function doLaunch() {
|
|||||||
projectName: item.name,
|
projectName: item.name,
|
||||||
sessionId: item.sessionId,
|
sessionId: item.sessionId,
|
||||||
targetBranch: item.targetBranch,
|
targetBranch: item.targetBranch,
|
||||||
width: Math.max(paneW, 20),
|
width: paneW,
|
||||||
height: Math.max(paneH, 6),
|
height: paneH,
|
||||||
})
|
})
|
||||||
sessionGrid!.addSession(session)
|
await directGrid!.addPane(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedProjects.clear()
|
selectedProjects.clear()
|
||||||
selectedSessions.clear()
|
selectedSessions.clear()
|
||||||
selectedBranches.clear()
|
selectedBranches.clear()
|
||||||
updateGridHeader()
|
|
||||||
updateGridFooter()
|
|
||||||
renderer.requestRender()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Grid View ─────────────────────────────────────────────────────
|
// ─── Grid View (Direct Renderer) ────────────────────────────────────
|
||||||
function ensureGridView() {
|
function ensureGridView() {
|
||||||
if (viewMode === "grid" && sessionGrid) return
|
if (viewMode === "grid" && directGrid) return
|
||||||
switchToGrid()
|
switchToGrid()
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchToGrid() {
|
function switchToGrid() {
|
||||||
viewMode = "grid"
|
viewMode = "grid"
|
||||||
if (mainBox) mainBox.visible = false
|
|
||||||
|
|
||||||
if (!gridContainer) {
|
if (!directGrid) {
|
||||||
gridHeader = new TextRenderable(renderer, {
|
directGrid = new DirectGridRenderer(rawStdoutWrite)
|
||||||
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 (gridContainer) gridContainer.visible = true
|
// Suspend OpenTUI render loop — this pauses stdin, exits raw mode, disables mouse
|
||||||
if (gridHeader) gridHeader.visible = true
|
renderer.suspend()
|
||||||
if (gridFooter) gridFooter.visible = true
|
|
||||||
updateGridHeader()
|
// Restore terminal state for direct rendering
|
||||||
updateGridFooter()
|
if (process.stdin.isTTY) process.stdin.setRawMode(true)
|
||||||
renderer.requestRender()
|
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() {
|
function switchToPicker() {
|
||||||
viewMode = "picker"
|
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 (mainBox) mainBox.visible = true
|
||||||
if (gridContainer) gridContainer.visible = false
|
|
||||||
if (gridHeader) gridHeader.visible = false
|
|
||||||
if (gridFooter) gridFooter.visible = false
|
|
||||||
updateAll()
|
updateAll()
|
||||||
renderer.requestRender()
|
renderer.requestRender()
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateGridHeader() {
|
function resizeGridPanes() {
|
||||||
if (!gridHeader) return
|
if (!directGrid || directGrid.paneCount === 0) return
|
||||||
const n = sessionGrid?.paneCount || 0
|
directGrid.repositionAll()
|
||||||
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.")}`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGridInput(rawSequence: string): Promise<boolean> {
|
async function handleGridInput(rawSequence: string): Promise<boolean> {
|
||||||
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
|
// Ctrl+` (0x1e) or ESC+` — return to picker
|
||||||
if (rawSequence === "\x1e" || rawSequence === "\x1b`") {
|
if (rawSequence === "\x1e" || rawSequence === "\x1b`") {
|
||||||
@@ -889,40 +963,50 @@ async function handleGridInput(rawSequence: string): Promise<boolean> {
|
|||||||
|
|
||||||
// Ctrl+N — focus next pane
|
// Ctrl+N — focus next pane
|
||||||
if (rawSequence === "\x0e") {
|
if (rawSequence === "\x0e") {
|
||||||
sessionGrid?.focusNext()
|
directGrid.focusNext()
|
||||||
updateGridHeader()
|
|
||||||
updateGridFooter()
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+P — focus previous pane
|
// Ctrl+P — focus previous pane
|
||||||
if (rawSequence === "\x10") {
|
if (rawSequence === "\x10") {
|
||||||
sessionGrid?.focusPrev()
|
directGrid.focusPrev()
|
||||||
updateGridHeader()
|
return true
|
||||||
updateGridFooter()
|
}
|
||||||
|
|
||||||
|
// 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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+W — close focused pane
|
// Ctrl+W — close focused pane
|
||||||
if (rawSequence === "\x17") {
|
if (rawSequence === "\x17") {
|
||||||
const pane = sessionGrid?.focusedPane
|
const pane = directGrid.focusedPane
|
||||||
if (pane) {
|
if (pane) {
|
||||||
const { killSession } = await import("./tmux/session-manager")
|
if (directGrid.isExpanded) directGrid.collapsePane()
|
||||||
sessionGrid!.removeSession(pane.session.name)
|
const { killSession } = await import("./pty/session-manager")
|
||||||
|
directGrid.removePane(pane.session.name)
|
||||||
await killSession(pane.session.name)
|
await killSession(pane.session.name)
|
||||||
updateGridHeader()
|
if (directGrid.paneCount === 0) {
|
||||||
updateGridFooter()
|
|
||||||
if (sessionGrid!.paneCount === 0) {
|
|
||||||
switchToPicker()
|
switchToPicker()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward everything else to the focused tmux pane
|
// PageUp / PageDown (Fn+Up/Down on Mac) — scroll focused pane
|
||||||
if (sessionGrid) {
|
if (rawSequence === "\x1b[5~") {
|
||||||
await sessionGrid.sendInputToFocused(rawSequence)
|
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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -950,16 +1034,25 @@ async function main() {
|
|||||||
sortedIndices = projects.map((_, i) => i)
|
sortedIndices = projects.map((_, i) => i)
|
||||||
rebuildDisplayRows()
|
rebuildDisplayRows()
|
||||||
|
|
||||||
|
// Save raw stdout.write BEFORE OpenTUI intercepts it
|
||||||
|
rawStdoutWrite = process.stdout.write.bind(process.stdout) as (s: string) => boolean
|
||||||
|
|
||||||
renderer = await createCliRenderer({
|
renderer = await createCliRenderer({
|
||||||
exitOnCtrlC: true,
|
exitOnCtrlC: true,
|
||||||
useAlternateScreen: true,
|
useAlternateScreen: true,
|
||||||
useMouse: true,
|
useMouse: false, // We handle mouse ourselves to avoid OpenTUI consuming events
|
||||||
onDestroy: () => {
|
onDestroy: () => {
|
||||||
destroyed = true
|
destroyed = true
|
||||||
if (monitorInterval) { clearInterval(monitorInterval); monitorInterval = null }
|
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
|
// Build layout
|
||||||
mainBox = new BoxRenderable(renderer, {
|
mainBox = new BoxRenderable(renderer, {
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
@@ -1055,15 +1148,273 @@ async function main() {
|
|||||||
updateUsagePanel()
|
updateUsagePanel()
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
|
|
||||||
// Intercept raw input for grid mode (before OpenTUI processes it)
|
// Resize PTY panes when terminal window is resized
|
||||||
renderer.prependInputHandler((sequence: string) => {
|
process.stdout.on("resize", () => {
|
||||||
if (viewMode !== "grid") return false
|
if (viewMode !== "grid" || !directGrid) return
|
||||||
// Handle grid input asynchronously, consume the event
|
resizeGridPanes()
|
||||||
handleGridInput(sequence)
|
|
||||||
return true
|
|
||||||
})
|
})
|
||||||
|
|
||||||
renderer.keyInput.on("keypress", handleKeypress)
|
// Shift+arrow sequences across terminal emulators
|
||||||
|
const SHIFT_ARROWS: Record<string, "up" | "down" | "left" | "right"> = {
|
||||||
|
"\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<string, { name: string; shift?: boolean; ctrl?: boolean }> = {
|
||||||
|
"\x1b[A": { name: "up" },
|
||||||
|
"\x1b[B": { name: "down" },
|
||||||
|
"\x1b[C": { name: "right" },
|
||||||
|
"\x1b[D": { name: "left" },
|
||||||
|
"\x1b[5~": { name: "pageup" },
|
||||||
|
"\x1b[6~": { name: "pagedown" },
|
||||||
|
"\x1b[H": { name: "home" },
|
||||||
|
"\x1b[F": { name: "end" },
|
||||||
|
"\x1bOH": { name: "home" },
|
||||||
|
"\x1bOF": { name: "end" },
|
||||||
|
"\x1b[Z": { name: "tab", shift: true },
|
||||||
|
"\x1b[1;2A": { name: "up", shift: true },
|
||||||
|
"\x1b[1;2B": { name: "down", shift: true },
|
||||||
|
"\x1b[1;2C": { name: "right", shift: true },
|
||||||
|
"\x1b[1;2D": { name: "left", shift: true },
|
||||||
|
"\x09": { name: "tab" },
|
||||||
|
"\x0d": { name: "return" },
|
||||||
|
"\x1b": { name: "escape" },
|
||||||
|
" ": { name: "space" },
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// Live session monitoring
|
||||||
if (demoMode) {
|
if (demoMode) {
|
||||||
@@ -1137,14 +1488,13 @@ async function main() {
|
|||||||
if (changed) updateAll()
|
if (changed) updateAll()
|
||||||
|
|
||||||
// Update grid pane statuses (flash idle sessions)
|
// Update grid pane statuses (flash idle sessions)
|
||||||
if (sessionGrid && viewMode === "grid") {
|
if (directGrid && viewMode === "grid") {
|
||||||
await refreshAlive()
|
await refreshAlive()
|
||||||
for (const [, s] of getSessions()) {
|
for (const [, s] of getSessions()) {
|
||||||
// Use monitor.ts to check busy/idle
|
|
||||||
const status = getSessionStatus(s.projectPath, s.sessionId)
|
const status = getSessionStatus(s.projectPath, s.sessionId)
|
||||||
if (status === "idle") sessionGrid.markIdle(s.name)
|
if (status === "idle") directGrid.markIdle(s.name)
|
||||||
else if (status === "busy") sessionGrid.markBusy(s.name)
|
else if (status === "busy") directGrid.markBusy(s.name)
|
||||||
else sessionGrid.clearMark(s.name)
|
else directGrid.clearMark(s.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
691
src/pty/capture.ts
Normal file
691
src/pty/capture.ts
Normal file
@@ -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<FrameCallback>
|
||||||
|
reader: ReadableStreamDefaultReader<Uint8Array> | null
|
||||||
|
running: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const panes = new Map<string, PaneState>()
|
||||||
|
|
||||||
|
// ─── VT Screen Buffer ─────────────────────────────────────────
|
||||||
|
|
||||||
|
interface VtCell {
|
||||||
|
char: string
|
||||||
|
sgr: string // accumulated SGR state as ANSI escape string
|
||||||
|
}
|
||||||
|
|
||||||
|
class VtScreen {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
cursorX = 0
|
||||||
|
cursorY = 0
|
||||||
|
cells: VtCell[][]
|
||||||
|
scrollback: VtCell[][] = [] // lines that scrolled off the top
|
||||||
|
scrollOffset = 0 // 0 = live view, >0 = scrolled back N lines
|
||||||
|
private static MAX_SCROLLBACK = 5000
|
||||||
|
private currentSgr = ""
|
||||||
|
private savedCursorX = 0
|
||||||
|
private savedCursorY = 0
|
||||||
|
private scrollTop = 0
|
||||||
|
private scrollBottom: number
|
||||||
|
private altScreen: VtCell[][] | null = null
|
||||||
|
private altCursorX = 0
|
||||||
|
private altCursorY = 0
|
||||||
|
|
||||||
|
constructor(width: number, height: number) {
|
||||||
|
this.width = width
|
||||||
|
this.height = height
|
||||||
|
this.scrollBottom = height - 1
|
||||||
|
this.cells = this.makeGrid(width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeGrid(w: number, h: number): VtCell[][] {
|
||||||
|
return Array.from({ length: h }, () =>
|
||||||
|
Array.from({ length: w }, () => ({ char: " ", sgr: "" }))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
resize(width: number, height: number) {
|
||||||
|
const newCells = this.makeGrid(width, height)
|
||||||
|
for (let r = 0; r < Math.min(height, this.height); r++) {
|
||||||
|
for (let c = 0; c < Math.min(width, this.width); c++) {
|
||||||
|
newCells[r][c] = this.cells[r]?.[c] ?? { char: " ", sgr: "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.cells = newCells
|
||||||
|
this.width = width
|
||||||
|
this.height = height
|
||||||
|
this.scrollTop = 0
|
||||||
|
this.scrollBottom = height - 1
|
||||||
|
if (this.cursorX >= width) this.cursorX = width - 1
|
||||||
|
if (this.cursorY >= height) this.cursorY = height - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write raw PTY output to the virtual screen
|
||||||
|
write(data: string) {
|
||||||
|
let i = 0
|
||||||
|
while (i < data.length) {
|
||||||
|
const c = data.charCodeAt(i)
|
||||||
|
|
||||||
|
// ESC sequences
|
||||||
|
if (c === 0x1b && i + 1 < data.length) {
|
||||||
|
const next = data[i + 1]
|
||||||
|
|
||||||
|
// CSI: \x1b[
|
||||||
|
if (next === "[") {
|
||||||
|
i = this.handleCSI(data, i + 2)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// OSC: \x1b] ... BEL or ST — skip
|
||||||
|
if (next === "]") {
|
||||||
|
i = this.skipOSC(data, i + 2)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// DCS/APC/PM: \x1bP, \x1b_, \x1b^ — skip to ST
|
||||||
|
if (next === "P" || next === "_" || next === "^") {
|
||||||
|
i = this.skipToST(data, i + 2)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// SS3: \x1bO — skip the next char
|
||||||
|
if (next === "O" && i + 2 < data.length) {
|
||||||
|
i += 3
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save cursor: \x1b7 or \x1b[s
|
||||||
|
if (next === "7") {
|
||||||
|
this.savedCursorX = this.cursorX
|
||||||
|
this.savedCursorY = this.cursorY
|
||||||
|
i += 2; continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore cursor: \x1b8 or \x1b[u
|
||||||
|
if (next === "8") {
|
||||||
|
this.cursorX = this.savedCursorX
|
||||||
|
this.cursorY = this.savedCursorY
|
||||||
|
i += 2; continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index (scroll up): \x1bD
|
||||||
|
if (next === "D") {
|
||||||
|
this.index()
|
||||||
|
i += 2; continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse index (scroll down): \x1bM
|
||||||
|
if (next === "M") {
|
||||||
|
this.reverseIndex()
|
||||||
|
i += 2; continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set tab stop, reset: skip
|
||||||
|
if (next === "H" || next === "c") {
|
||||||
|
i += 2; continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown ESC — skip
|
||||||
|
i += 2; continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// C0 control characters
|
||||||
|
if (c === 0x0d) { // CR
|
||||||
|
this.cursorX = 0
|
||||||
|
i++; continue
|
||||||
|
}
|
||||||
|
if (c === 0x0a) { // LF
|
||||||
|
if (this.cursorY === this.scrollBottom) {
|
||||||
|
this.scrollUp()
|
||||||
|
} else if (this.cursorY < this.height - 1) {
|
||||||
|
this.cursorY++
|
||||||
|
}
|
||||||
|
i++; continue
|
||||||
|
}
|
||||||
|
if (c === 0x08) { // BS
|
||||||
|
if (this.cursorX > 0) this.cursorX--
|
||||||
|
i++; continue
|
||||||
|
}
|
||||||
|
if (c === 0x09) { // TAB
|
||||||
|
this.cursorX = Math.min(((this.cursorX >> 3) + 1) << 3, this.width - 1)
|
||||||
|
i++; continue
|
||||||
|
}
|
||||||
|
if (c === 0x07) { // BEL
|
||||||
|
i++; continue
|
||||||
|
}
|
||||||
|
if (c < 0x20 && c !== 0x1b) {
|
||||||
|
i++; continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Printable character
|
||||||
|
if (this.cursorX >= this.width) {
|
||||||
|
// Auto-wrap
|
||||||
|
this.cursorX = 0
|
||||||
|
if (this.cursorY === this.scrollBottom) {
|
||||||
|
this.scrollUp()
|
||||||
|
} else if (this.cursorY < this.height - 1) {
|
||||||
|
this.cursorY++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = this.cells[this.cursorY]
|
||||||
|
if (row && this.cursorX < this.width) {
|
||||||
|
row[this.cursorX] = { char: data[i], sgr: this.currentSgr }
|
||||||
|
}
|
||||||
|
this.cursorX++
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 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<string, number>()
|
||||||
|
|
||||||
|
export function hasChanged(lines: string[], key = "_default"): boolean {
|
||||||
|
let h = 5381
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]
|
||||||
|
for (let j = 0; j < line.length; j++) {
|
||||||
|
h = ((h << 5) + h + line.charCodeAt(j)) | 0
|
||||||
|
}
|
||||||
|
h = ((h << 5) + h + 10) | 0
|
||||||
|
}
|
||||||
|
const prev = lastHashes.get(key)
|
||||||
|
if (prev === h) return false
|
||||||
|
lastHashes.set(key, h)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetHash(key = "_default") {
|
||||||
|
lastHashes.delete(key)
|
||||||
|
}
|
||||||
142
src/pty/session-manager.ts
Normal file
142
src/pty/session-manager.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// Direct PTY session management — no tmux.
|
||||||
|
// Each session spawns a pty-helper subprocess that owns a real PTY.
|
||||||
|
// I/O flows through Bun.spawn stdin/stdout pipes.
|
||||||
|
|
||||||
|
import type { Subprocess } from "bun"
|
||||||
|
import { resolve } from "path"
|
||||||
|
|
||||||
|
export interface PtySession {
|
||||||
|
name: string
|
||||||
|
projectPath: string
|
||||||
|
projectName: string
|
||||||
|
sessionId?: string
|
||||||
|
targetBranch?: string
|
||||||
|
alive: boolean
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
colorIndex: number
|
||||||
|
proc: Subprocess<"pipe", "pipe", "pipe">
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = new Map<string, PtySession>()
|
||||||
|
let colorCounter = 0
|
||||||
|
|
||||||
|
// Resolve pty-helper binary path relative to this source file
|
||||||
|
const PTY_HELPER = resolve(import.meta.dir, "..", "..", "bin", "pty-helper")
|
||||||
|
|
||||||
|
export function getSessions(): Map<string, PtySession> {
|
||||||
|
return sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSessionByProject(projectPath: string): PtySession[] {
|
||||||
|
return [...sessions.values()].filter(s => s.projectPath === projectPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSession(opts: {
|
||||||
|
projectPath: string
|
||||||
|
projectName: string
|
||||||
|
sessionId?: string
|
||||||
|
targetBranch?: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}): Promise<PtySession> {
|
||||||
|
const slug = opts.projectName.replace(/[^a-zA-Z0-9-]/g, "-").slice(0, 20)
|
||||||
|
const ts = Date.now().toString(36)
|
||||||
|
const name = `cladm-${slug}-${ts}`
|
||||||
|
|
||||||
|
const cmd = buildClaudeCmd(opts.projectPath, opts.sessionId, opts.targetBranch)
|
||||||
|
|
||||||
|
const proc = Bun.spawn([
|
||||||
|
PTY_HELPER,
|
||||||
|
String(opts.height),
|
||||||
|
String(opts.width),
|
||||||
|
"bash", "-c", cmd,
|
||||||
|
], {
|
||||||
|
stdin: "pipe",
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
env: { ...process.env, TERM: "xterm-256color", CLAUDECODE: "" },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Assign color index based on project path (same project = same color)
|
||||||
|
const existingForProject = getSessionByProject(opts.projectPath)
|
||||||
|
const ci = existingForProject.length > 0
|
||||||
|
? existingForProject[0].colorIndex
|
||||||
|
: colorCounter++
|
||||||
|
|
||||||
|
const session: PtySession = {
|
||||||
|
name,
|
||||||
|
projectPath: opts.projectPath,
|
||||||
|
projectName: opts.projectName,
|
||||||
|
sessionId: opts.sessionId,
|
||||||
|
targetBranch: opts.targetBranch,
|
||||||
|
alive: true,
|
||||||
|
width: opts.width,
|
||||||
|
height: opts.height,
|
||||||
|
colorIndex: ci,
|
||||||
|
proc,
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions.set(name, session)
|
||||||
|
|
||||||
|
// Monitor for exit
|
||||||
|
proc.exited.then(() => {
|
||||||
|
session.alive = false
|
||||||
|
})
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
export function killSession(name: string): void {
|
||||||
|
const session = sessions.get(name)
|
||||||
|
if (!session) return
|
||||||
|
if (!session.proc.killed) {
|
||||||
|
session.proc.stdin.end()
|
||||||
|
session.proc.kill()
|
||||||
|
}
|
||||||
|
session.alive = false
|
||||||
|
sessions.delete(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resizeSession(name: string, width: number, height: number): void {
|
||||||
|
const session = sessions.get(name)
|
||||||
|
if (!session || !session.alive) return
|
||||||
|
session.width = width
|
||||||
|
session.height = height
|
||||||
|
// Send APC resize command: \x1b_R<rows>;<cols>\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}`
|
||||||
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
// Captures tmux pane content via persistent streaming subprocesses.
|
||||||
|
// Push-based: notifies subscribers immediately when new frames arrive.
|
||||||
|
// Zero polling overhead on the JS side — callbacks fire on stream data.
|
||||||
|
|
||||||
|
import type { Subprocess } from "bun"
|
||||||
|
|
||||||
export interface CaptureResult {
|
export interface CaptureResult {
|
||||||
lines: string[]
|
lines: string[]
|
||||||
cursorX: number
|
cursorX: number
|
||||||
@@ -6,29 +12,157 @@ export interface CaptureResult {
|
|||||||
height: number
|
height: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function capturePane(sessionName: string): Promise<CaptureResult | null> {
|
const SEP = "%%CLADM_FRAME%%"
|
||||||
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 [contentText, infoText] = await Promise.all([
|
type FrameCallback = (frame: CaptureResult) => void
|
||||||
new Response(contentProc.stdout).text(),
|
|
||||||
new Response(infoProc.stdout).text(),
|
|
||||||
])
|
|
||||||
|
|
||||||
const [codeContent, codeInfo] = await Promise.all([contentProc.exited, infoProc.exited])
|
interface PaneCapture {
|
||||||
if (codeContent !== 0 || codeInfo !== 0) return null
|
proc: Subprocess<"ignore", "pipe", "ignore">
|
||||||
|
latest: CaptureResult | null
|
||||||
|
buf: string
|
||||||
|
callbacks: Set<FrameCallback>
|
||||||
|
}
|
||||||
|
|
||||||
const parts = infoText.trim().split(" ")
|
const panes = new Map<string, PaneCapture>()
|
||||||
|
|
||||||
|
export function startCapture(sessionName: string, intervalMs = 100): void {
|
||||||
|
if (panes.has(sessionName)) return
|
||||||
|
|
||||||
|
const script = `while true; do
|
||||||
|
tmux capture-pane -t '${sessionName}' -p -e 2>/dev/null
|
||||||
|
echo '${SEP}'
|
||||||
|
tmux display-message -t '${sessionName}' -p '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}' 2>/dev/null
|
||||||
|
echo '${SEP}END'
|
||||||
|
sleep ${(intervalMs / 1000).toFixed(3)}
|
||||||
|
done`
|
||||||
|
|
||||||
|
const proc = Bun.spawn(["sh", "-c", script], {
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "ignore",
|
||||||
|
})
|
||||||
|
|
||||||
|
const state: PaneCapture = { proc, latest: null, buf: "", callbacks: new Set() }
|
||||||
|
panes.set(sessionName, state)
|
||||||
|
|
||||||
|
const reader = proc.stdout.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
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<CaptureResult | null> {
|
||||||
|
const latest = getLatestFrame(sessionName)
|
||||||
|
if (latest) return latest
|
||||||
|
|
||||||
|
try {
|
||||||
|
const proc = Bun.spawn(["sh", "-c",
|
||||||
|
`tmux capture-pane -t '${sessionName}' -p -e && echo '${SEP}' && tmux display-message -t '${sessionName}' -p '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`
|
||||||
|
], { stdout: "pipe", stderr: "ignore" })
|
||||||
|
|
||||||
|
const text = await new Response(proc.stdout).text()
|
||||||
|
const code = await proc.exited
|
||||||
|
if (code !== 0) return null
|
||||||
|
|
||||||
|
const sepIdx = text.lastIndexOf(SEP)
|
||||||
|
if (sepIdx < 0) return null
|
||||||
|
|
||||||
|
const contentText = text.slice(0, sepIdx)
|
||||||
|
const infoText = text.slice(sepIdx + SEP.length).trim()
|
||||||
|
|
||||||
|
const parts = infoText.split(" ")
|
||||||
const cursorX = parseInt(parts[0]) || 0
|
const cursorX = parseInt(parts[0]) || 0
|
||||||
const cursorY = parseInt(parts[1]) || 0
|
const cursorY = parseInt(parts[1]) || 0
|
||||||
const width = parseInt(parts[2]) || 80
|
const width = parseInt(parts[2]) || 80
|
||||||
const height = parseInt(parts[3]) || 24
|
const height = parseInt(parts[3]) || 24
|
||||||
|
|
||||||
const lines = contentText.split("\n")
|
const lines = contentText.split("\n")
|
||||||
// Remove trailing empty line from split
|
|
||||||
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop()
|
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop()
|
||||||
|
|
||||||
return { lines, cursorX, cursorY, width, height }
|
return { lines, cursorX, cursorY, width, height }
|
||||||
@@ -37,17 +171,24 @@ export async function capturePane(sessionName: string): Promise<CaptureResult |
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash for diffing - skip re-render if nothing changed
|
// Hash for diffing — skip re-render if nothing changed
|
||||||
let lastHash = ""
|
const lastHashes = new Map<string, number>()
|
||||||
|
|
||||||
export function hasChanged(lines: string[]): boolean {
|
export function hasChanged(lines: string[], key = "_default"): boolean {
|
||||||
// Simple fast hash: join first+last few lines + length
|
let h = 5381
|
||||||
const h = lines.length + ":" + (lines[0] || "") + (lines[lines.length - 1] || "")
|
for (let i = 0; i < lines.length; i++) {
|
||||||
if (h === lastHash) return false
|
const line = lines[i]
|
||||||
lastHash = h
|
for (let j = 0; j < line.length; j++) {
|
||||||
|
h = ((h << 5) + h + line.charCodeAt(j)) | 0
|
||||||
|
}
|
||||||
|
h = ((h << 5) + h + 10) | 0
|
||||||
|
}
|
||||||
|
const prev = lastHashes.get(key)
|
||||||
|
if (prev === h) return false
|
||||||
|
lastHashes.set(key, h)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetHash() {
|
export function resetHash(key = "_default") {
|
||||||
lastHash = ""
|
lastHashes.delete(key)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,77 @@
|
|||||||
// Forwards raw terminal input sequences to a tmux session
|
// Forwards raw terminal input to tmux sessions via a persistent shell.
|
||||||
|
// Zero process-spawn overhead per keystroke — writes to stdin of a long-lived sh.
|
||||||
|
|
||||||
export async function sendKeys(sessionName: string, rawSequence: string): Promise<void> {
|
import type { Subprocess } from "bun"
|
||||||
// Use -l for literal text to avoid tmux key-name interpretation
|
|
||||||
// But special keys need to be sent without -l
|
let shell: Subprocess<"ignore", "pipe", "ignore"> | null = null
|
||||||
|
|
||||||
|
function getShell() {
|
||||||
|
if (shell && !shell.killed) return shell
|
||||||
|
shell = Bun.spawn(["sh"], {
|
||||||
|
stdin: "pipe",
|
||||||
|
stdout: "ignore",
|
||||||
|
stderr: "ignore",
|
||||||
|
})
|
||||||
|
return shell
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendKeys(sessionName: string, rawSequence: string): void {
|
||||||
|
const sh = getShell()
|
||||||
const special = mapSpecialSequence(rawSequence)
|
const special = mapSpecialSequence(rawSequence)
|
||||||
|
|
||||||
|
let cmd: string
|
||||||
if (special) {
|
if (special) {
|
||||||
const proc = Bun.spawn(["tmux", "send-keys", "-t", sessionName, special], {
|
cmd = `tmux send-keys -t '${sessionName}' ${special}\n`
|
||||||
stdout: "ignore", stderr: "ignore",
|
|
||||||
})
|
|
||||||
await proc.exited
|
|
||||||
} else {
|
} else {
|
||||||
// Literal text - send as hex to avoid escaping issues
|
const hex = [...rawSequence].map(c => c.charCodeAt(0).toString(16).padStart(2, "0")).join(" ")
|
||||||
const hexBytes = [...rawSequence].map(c => {
|
cmd = `tmux send-keys -t '${sessionName}' -H ${hex}\n`
|
||||||
const code = c.charCodeAt(0)
|
|
||||||
return code.toString(16).padStart(2, "0")
|
|
||||||
})
|
|
||||||
const proc = Bun.spawn(["tmux", "send-keys", "-t", sessionName, "-H", ...hexBytes], {
|
|
||||||
stdout: "ignore", stderr: "ignore",
|
|
||||||
})
|
|
||||||
await proc.exited
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sh.stdin.write(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward mouse events to tmux as SGR escape sequences
|
||||||
|
export function sendMouseEvent(sessionName: string, x: number, y: number, btn: number, release: boolean): void {
|
||||||
|
const sh = getShell()
|
||||||
|
const end = release ? "m" : "M"
|
||||||
|
const seq = `\x1b[<${btn};${x};${y}${end}`
|
||||||
|
const hex = [...seq].map(c => c.charCodeAt(0).toString(16).padStart(2, "0")).join(" ")
|
||||||
|
const cmd = `tmux send-keys -t '${sessionName}' -H ${hex}\n`
|
||||||
|
sh.stdin.write(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll a tmux pane using copy-mode (works with any application in the pane)
|
||||||
|
export function sendScroll(sessionName: string, direction: "up" | "down", lines = 3): void {
|
||||||
|
const sh = getShell()
|
||||||
|
if (direction === "up") {
|
||||||
|
// Enter copy mode (no-op if already in it) then scroll up
|
||||||
|
sh.stdin.write(`tmux copy-mode -t '${sessionName}' 2>/dev/null; tmux send-keys -t '${sessionName}' -X -N ${lines} scroll-up 2>/dev/null\n`)
|
||||||
|
} else {
|
||||||
|
// Scroll down in copy mode; if we hit bottom, exit copy mode
|
||||||
|
sh.stdin.write(`tmux send-keys -t '${sessionName}' -X -N ${lines} scroll-down 2>/dev/null\n`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit copy mode (e.g., when user starts typing)
|
||||||
|
export function exitCopyMode(sessionName: string): void {
|
||||||
|
const sh = getShell()
|
||||||
|
sh.stdin.write(`tmux send-keys -t '${sessionName}' -X cancel 2>/dev/null\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanupInputQueue(_sessionName: string) {
|
||||||
|
// No per-session cleanup needed with shared shell
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroyShell() {
|
||||||
|
if (shell && !shell.killed) {
|
||||||
|
shell.stdin.end()
|
||||||
|
shell.kill()
|
||||||
|
}
|
||||||
|
shell = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map known ANSI escape sequences to tmux key names
|
// Map known ANSI escape sequences to tmux key names
|
||||||
function mapSpecialSequence(seq: string): string | null {
|
function mapSpecialSequence(seq: string): string | null {
|
||||||
// Common escape sequences -> tmux key names
|
|
||||||
const MAP: Record<string, string> = {
|
const MAP: Record<string, string> = {
|
||||||
"\r": "Enter",
|
"\r": "Enter",
|
||||||
"\n": "Enter",
|
"\n": "Enter",
|
||||||
|
|||||||
@@ -63,6 +63,12 @@ export async function createSession(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sessions.set(name, session)
|
sessions.set(name, session)
|
||||||
|
|
||||||
|
// Enable mouse mode so clicks/scrolls forward to the application
|
||||||
|
Bun.spawn(["tmux", "set", "-t", name, "mouse", "on"], {
|
||||||
|
stdout: "ignore", stderr: "ignore",
|
||||||
|
})
|
||||||
|
|
||||||
return session
|
return session
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user