From 3ce657295212af24e48addb2c449476951f43e6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 28 Feb 2026 01:41:31 +0000 Subject: [PATCH 01/13] 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 --- bun.lock | 5 + package.json | 3 +- src/actions/launcher.ts | 12 +- src/components/direct-grid.ts | 538 +++++++++++++++++++++++++ src/components/direct-pane.ts | 74 ++++ src/components/session-grid.ts | 189 ++++++++- src/components/terminal-view.ts | 63 +-- src/index.ts | 576 ++++++++++++++++++++------ src/pty/capture.ts | 691 ++++++++++++++++++++++++++++++++ src/pty/session-manager.ts | 142 +++++++ src/tmux/capture.ts | 187 +++++++-- src/tmux/input-bridge.ts | 82 +++- src/tmux/session-manager.ts | 6 + 13 files changed, 2374 insertions(+), 194 deletions(-) create mode 100644 src/components/direct-grid.ts create mode 100644 src/components/direct-pane.ts create mode 100644 src/pty/capture.ts create mode 100644 src/pty/session-manager.ts diff --git a/bun.lock b/bun.lock index a8bbe7f..1946f60 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "tui-claude-director", "dependencies": { "@opentui/core": "^0.1.81", + "node-pty": "^1.1.0", }, "devDependencies": { "@types/bun": "latest", @@ -146,6 +147,10 @@ "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + + "node-pty": ["node-pty@1.1.0", "", { "dependencies": { "node-addon-api": "^7.1.0" } }, "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg=="], + "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], diff --git a/package.json b/package.json index fd05060..f47b484 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "typescript": "^5" }, "dependencies": { - "@opentui/core": "^0.1.81" + "@opentui/core": "^0.1.81", + "node-pty": "^1.1.0" } } diff --git a/src/actions/launcher.ts b/src/actions/launcher.ts index 5c66bdd..260a49a 100644 --- a/src/actions/launcher.ts +++ b/src/actions/launcher.ts @@ -1,4 +1,5 @@ import type { Project } from "../lib/types" +import { loadSessions } from "../data/sessions" interface LaunchItem { path: string @@ -19,7 +20,16 @@ export async function launchSelections( const targetBranch = selectedBranches.get(path) const project = projects.find(p => p.path === path) const needsBranch = targetBranch && project && targetBranch !== project.branch - byProject.get(path)!.push({ path, targetBranch: needsBranch ? targetBranch : undefined }) + // Auto-resume most recent session + let lastSessionId: string | undefined + if (project) { + if (!project.sessions) { + project.sessions = await loadSessions(project.path) + project.sessionCount = project.sessions.length + } + lastSessionId = project.sessions[0]?.id + } + byProject.get(path)!.push({ path, sessionId: lastSessionId, targetBranch: needsBranch ? targetBranch : undefined }) } for (const project of projects) { diff --git a/src/components/direct-grid.ts b/src/components/direct-grid.ts new file mode 100644 index 0000000..84ec3a4 --- /dev/null +++ b/src/components/direct-grid.ts @@ -0,0 +1,538 @@ +// Direct grid renderer: bypasses OpenTUI entirely for grid mode. +// Draws chrome (borders/titles) and pane content using raw ANSI cursor-addressed writes. +// Each pane renders independently via PTY capture push callbacks. + +import { DirectPane } from "./direct-pane" +import { startCapture, stopCapture, resizeCapture, resetHash, getLatestFrame, scrollPane, getScrollOffset } from "../pty/capture" +import { writeToSession, resizeSession, killSession, type PtySession } from "../pty/session-manager" + +export type PaneStatus = "busy" | "idle" | null + +export interface GridPaneInfo { + session: PtySession + directPane: DirectPane + status: PaneStatus + statusSince: number +} + +const PROJECT_COLORS = [ + "#7aa2f7", "#9ece6a", "#e0af68", "#f7768e", "#bb9af7", + "#7dcfff", "#ff9e64", "#c0caf5", "#73daca", "#b4f9f8", +] + +function getColor(idx: number): string { + return PROJECT_COLORS[idx % PROJECT_COLORS.length]! +} + +function hexFg(hex: string): string { + const r = parseInt(hex.slice(1, 3), 16) + const g = parseInt(hex.slice(3, 5), 16) + const b = parseInt(hex.slice(5, 7), 16) + return `\x1b[38;2;${r};${g};${b}m` +} + +const RESET = "\x1b[0m" +const BOLD = "\x1b[1m" +const DIM = "\x1b[2m" +const WHITE = "\x1b[38;2;255;255;255m" +const HIDE_CURSOR = "\x1b[?25l" +const SHOW_CURSOR = "\x1b[?25h" +const SYNC_START = "\x1b[?2026h" +const SYNC_END = "\x1b[?2026l" +const CLEAR = "\x1b[2J\x1b[H" + +function fmtElapsed(sinceMs: number): string { + if (!sinceMs) return "" + const sec = Math.floor((Date.now() - sinceMs) / 1000) + if (sec < 1) return "0s" + if (sec < 60) return `${sec}s` + const m = Math.floor(sec / 60) + const s = sec % 60 + if (m < 60) return `${m}m${s > 0 ? String(s).padStart(2, "0") + "s" : ""}` + const h = Math.floor(m / 60) + const rm = m % 60 + return `${h}h${rm > 0 ? String(rm).padStart(2, "0") + "m" : ""}` +} + +export class DirectGridRenderer { + private panes: GridPaneInfo[] = [] + private _focusIndex = 0 + private writeRaw: (s: string) => boolean + private flashTimers = new Map>() + private titleTimer: ReturnType | null = null + private running = false + private _selectMode = false + private _expandedIndex = -1 // -1 = grid view, >=0 = expanded pane index + + constructor(rawWrite: (s: string) => boolean) { + this.writeRaw = rawWrite + } + + // ─── Lifecycle ───────────────────────────────────────── + + start() { + this.running = true + this.writeRaw(HIDE_CURSOR + CLEAR) + this.drawChrome() + this.titleTimer = setInterval(() => this.refreshTitles(), 1000) + } + + stop() { + this.running = false + if (this.titleTimer) { clearInterval(this.titleTimer); this.titleTimer = null } + for (const timer of this.flashTimers.values()) clearInterval(timer) + this.flashTimers.clear() + for (const p of this.panes) { + p.directPane.detach() + stopCapture(p.session.name) + } + this.writeRaw(SHOW_CURSOR) + } + + // ─── Getters ─────────────────────────────────────────── + + get focusIndex() { return this._focusIndex } + get paneCount() { return this.panes.length } + get focusedPane(): GridPaneInfo | null { return this.panes[this._focusIndex] ?? null } + get selectMode() { return this._selectMode } + get isExpanded() { return this._expandedIndex >= 0 } + + enterSelectMode() { + if (!this.isExpanded) return // Only allow in expanded mode + this._selectMode = true + this.writeRaw("\x1b[?1000l\x1b[?1006l") // Disable mouse reporting + this.writeRaw(SHOW_CURSOR) + this.drawChrome() + } + + exitSelectMode() { + this._selectMode = false + this.writeRaw("\x1b[?1000h\x1b[?1006h") // Re-enable mouse reporting + this.writeRaw(HIDE_CURSOR) + this.drawChrome() + } + + expandPane(index?: number) { + const idx = index ?? this._focusIndex + if (idx < 0 || idx >= this.panes.length) return + this._expandedIndex = idx + this._focusIndex = idx + this.repositionAll() + } + + collapsePane() { + if (this._selectMode) this.exitSelectMode() + this._expandedIndex = -1 + this.repositionAll() + } + + // Check if a click hit a button on the top border. Returns action + pane index. + checkButtonClick(col: number, row: number): { action: "max" | "min" | "sel", paneIndex: number } | null { + const indicesToCheck = this.isExpanded ? [this._expandedIndex] : this.panes.map((_, i) => i) + for (const i of indicesToCheck) { + const dp = this.panes[i]!.directPane + const bx = dp.screenX - 1 + const by = dp.screenY - 3 + const bw = dp.width + 2 + const btnRow = by + + if (row !== btnRow) continue + + if (this.isExpanded) { + // Expanded: buttons are [SEL] and [MIN] at top-right + // Layout: ...hz [SEL] hz [MIN] hz tr + const minRight = bx + bw - 2 + const minLeft = minRight - 4 + if (col >= minLeft && col <= minRight) return { action: "min", paneIndex: i } + const selRight = minLeft - 2 + const selLeft = selRight - 4 + if (col >= selLeft && col <= selRight) return { action: "sel", paneIndex: i } + } else { + // Grid: button is [MAX] at top-right + const btnLeft = bx + bw - 7 + const btnRight = bx + bw - 3 + if (col >= btnLeft && col <= btnRight) return { action: "max", paneIndex: i } + } + } + return null + } + + // ─── Pane management ─────────────────────────────────── + + async addPane(session: PtySession): Promise { + const regions = this.calcPaneRegions(this.panes.length + 1) + const idx = this.panes.length + const region = regions[idx]! + + const dp = new DirectPane(region.screenX, region.screenY, region.contentW, region.contentH) + const info: GridPaneInfo = { session, directPane: dp, status: null, statusSince: 0 } + this.panes.push(info) + + // Resize PTY to match content area + resizeSession(session.name, region.contentW, region.contentH) + + // Start capture (reads PTY stdout and pushes frames) + startCapture(session) + + // Subscribe to push frames (must set callback AFTER attach, since attach calls detach which nulls onFrame) + dp.attach(session.name) + dp.onFrame = (lines) => { + if (!this.running) return + this.drawPane(idx, lines) + } + + // Reposition all existing panes + this.repositionAll() + + return info + } + + removePane(sessionName: string) { + const idx = this.panes.findIndex(p => p.session.name === sessionName) + if (idx < 0) return + + const pane = this.panes[idx]! + pane.directPane.detach() + stopCapture(pane.session.name) + killSession(pane.session.name) + this.clearFlash(sessionName) + this.panes.splice(idx, 1) + + if (this._focusIndex >= this.panes.length) { + this._focusIndex = Math.max(0, this.panes.length - 1) + } + + this.repositionAll() + } + + // ─── Focus ───────────────────────────────────────────── + + setFocus(index: number) { + if (index < 0 || index >= this.panes.length) return + this._focusIndex = index + this.drawChrome() + } + + focusNext() { + if (this.panes.length === 0) return + this.setFocus((this._focusIndex + 1) % this.panes.length) + } + + focusPrev() { + if (this.panes.length === 0) return + this.setFocus((this._focusIndex - 1 + this.panes.length) % this.panes.length) + } + + focusByDirection(dir: "up" | "down" | "left" | "right") { + const n = this.panes.length + if (n <= 1) return + const { cols } = this.calcGrid(n) + const rows = Math.ceil(n / cols) + const curCol = this._focusIndex % cols + const curRow = Math.floor(this._focusIndex / cols) + let nc = curCol, nr = curRow + switch (dir) { + case "left": nc = (curCol - 1 + cols) % cols; break + case "right": nc = (curCol + 1) % cols; break + case "up": nr = (curRow - 1 + rows) % rows; break + case "down": nr = (curRow + 1) % rows; break + } + const idx = nr * cols + nc + if (idx >= 0 && idx < n) this.setFocus(idx) + } + + focusByClick(col: number, row: number): boolean { + const n = this.panes.length + if (n === 0) return false + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const { cols } = this.calcGrid(n) + const rows = Math.ceil(n / cols) + const cellW = Math.floor(termW / cols) + const cellH = Math.floor((termH - 2) / rows) + const gc = Math.floor((col - 1) / cellW) + const gr = Math.floor((row - 2) / cellH) + const idx = gr * cols + gc + if (idx >= 0 && idx < n) { + this.setFocus(idx) + return true + } + return false + } + + // ─── Chrome ──────────────────────────────────────────── + + drawChrome() { + if (!this.running) return + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + + let out = SYNC_START + + // Header (row 1) + const n = this.panes.length + const fi = this._focusIndex + 1 + let headerLeft: string, headerRight: string + if (this._selectMode) { + headerLeft = ` ${BOLD}cladm grid${RESET} — ${hexFg("#9ece6a")}${BOLD}SELECT MODE${RESET}` + headerRight = `${DIM}drag to select │ cmd+c copy │ ${BOLD}Esc${RESET}${DIM} exit select${RESET}` + } else if (this.isExpanded) { + headerLeft = ` ${BOLD}cladm grid${RESET} — ${hexFg("#7dcfff")}${BOLD}EXPANDED${RESET} │ ${fi}/${n}` + headerRight = `${DIM}click ${BOLD}[SEL]${RESET}${DIM} select text │ click ${BOLD}[MIN]${RESET}${DIM} restore │ ctrl+\` picker${RESET}` + } else { + headerLeft = ` ${BOLD}cladm grid${RESET} — ${n} sessions │ focus: ${fi}/${n}` + headerRight = `${DIM}shift+arrows nav │ scroll/pgup/dn │ click ${BOLD}[MAX]${RESET}${DIM} expand │ ctrl+\` picker │ ctrl+w close${RESET}` + } + out += `\x1b[1;1H\x1b[${termW}X${headerLeft} ${headerRight}` + + // Pane borders + titles + if (this.isExpanded) { + out += this.drawPaneBorder(this._expandedIndex) + } else { + for (let i = 0; i < this.panes.length; i++) { + out += this.drawPaneBorder(i) + } + } + + // Footer (last row) + const pane = this.focusedPane + if (this._selectMode) { + out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg("#9ece6a")}${BOLD}SELECT MODE${RESET} ${DIM}drag to select text │ cmd+c to copy │ press ${BOLD}Esc${RESET}${DIM} to exit${RESET}` + } else if (this.isExpanded && pane) { + const color = getColor(pane.session.colorIndex) + out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg(color)}▸${RESET} ${BOLD}${pane.session.projectName}${RESET} ${DIM}expanded │ Esc or [MIN] to restore grid${RESET}` + } else if (pane) { + const color = getColor(pane.session.colorIndex) + const sid = pane.session.sessionId ? ` ${DIM}#${pane.session.sessionId.slice(0, 8)}${RESET}` : "" + out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg(color)}▸${RESET} ${BOLD}${pane.session.projectName}${RESET}${sid} ${DIM}all input goes to focused pane${RESET}` + } else { + out += `\x1b[${termH};1H\x1b[${termW}X ${DIM}No sessions. Press ctrl+\` to return to picker.${RESET}` + } + + out += SYNC_END + this.writeRaw(out) + } + + private drawPaneBorder(index: number): string { + const pane = this.panes[index]! + const dp = pane.directPane + const isFocused = index === this._focusIndex + const isFlashing = this.flashTimers.has(pane.session.name) + + const color = getColor(pane.session.colorIndex) + let borderColor: string + if (isFocused) borderColor = WHITE + else if (isFlashing) borderColor = hexFg("#ff9e64") + else borderColor = hexFg(color) + + const tl = isFocused ? "┏" : "╭" + const tr = isFocused ? "┓" : "╮" + const bl = isFocused ? "┗" : "╰" + const br = isFocused ? "┛" : "╯" + const hz = isFocused ? "━" : "─" + const vt = isFocused ? "┃" : "│" + + const bx = dp.screenX - 1 + const by = dp.screenY - 3 + const bw = dp.width + 2 + const bh = dp.height + 4 + + let out = "" + + // Top border with buttons + let btnSection: string + let btnVisibleLen: number + if (this.isExpanded) { + // Expanded: [SEL] [MIN] at right + const selColor = this._selectMode ? `${hexFg("#9ece6a")}${BOLD}` : `${DIM}` + btnSection = `${RESET}${selColor}[SEL]${RESET}${borderColor}${hz}${RESET}${hexFg("#7dcfff")}[MIN]${RESET}${borderColor}` + btnVisibleLen = 5 + 1 + 5 // [SEL] + hz + [MIN] + } else { + // Grid: [MAX] at right + btnSection = `${RESET}${DIM}[MAX]${RESET}${borderColor}` + btnVisibleLen = 5 // [MAX] + } + const hzFill = Math.max(0, bw - 2 - btnVisibleLen - 1) // -2 corners, -1 trailing hz + out += `\x1b[${by};${bx}H${borderColor}${tl}${hz.repeat(hzFill)}${btnSection}${hz}${tr}${RESET}` + + // Title row + const nameColor = hexFg(color) + const name = pane.session.projectName + const elapsed = pane.statusSince ? fmtElapsed(pane.statusSince) : "" + const scrollOff = getScrollOffset(pane.session.name) + const scrollTag = scrollOff > 0 ? ` ${hexFg("#ff9e64")}[SCROLL +${scrollOff}]${RESET}` : "" + let titleContent: string + if (pane.status === "idle") { + titleContent = ` ${nameColor}${BOLD}${name}${RESET} ${hexFg("#e0af68")}IDLE${RESET} ${DIM}${elapsed}${RESET}${scrollTag}` + } else if (pane.status === "busy") { + titleContent = ` ${nameColor}${BOLD}${name}${RESET} ${hexFg("#9ece6a")}RUNNING${RESET} ${DIM}${elapsed}${RESET}${scrollTag}` + } else { + const sid = pane.session.sessionId ? ` ${DIM}#${pane.session.sessionId.slice(0, 8)}${RESET}` : "" + titleContent = ` ${nameColor}${BOLD}${name}${RESET}${sid}${scrollTag}` + } + out += `\x1b[${by + 1};${bx}H${borderColor}${vt}${RESET}\x1b[${bw - 2}X${titleContent}` + out += `\x1b[${by + 1};${bx + bw - 1}H${borderColor}${vt}${RESET}` + + // Subtitle row + out += `\x1b[${by + 2};${bx}H${borderColor}${vt}${RESET}\x1b[${bw - 2}X ${DIM}${pane.session.projectPath}${RESET}` + out += `\x1b[${by + 2};${bx + bw - 1}H${borderColor}${vt}${RESET}` + + // Side borders for content rows + for (let r = 0; r < dp.height; r++) { + out += `\x1b[${dp.screenY + r};${bx}H${borderColor}${vt}${RESET}` + out += `\x1b[${dp.screenY + r};${bx + bw - 1}H${borderColor}${vt}${RESET}` + } + + // Bottom border + out += `\x1b[${by + bh - 1};${bx}H${borderColor}${bl}${hz.repeat(bw - 2)}${br}${RESET}` + + return out + } + + // ─── Content rendering ───────────────────────────────── + + private drawPane(index: number, lines: string[]) { + // In expanded mode, only draw the expanded pane + if (this.isExpanded && index !== this._expandedIndex) return + const pane = this.panes[index] + if (!pane) return + const frame = pane.directPane.buildFrame(lines) + this.writeRaw(SYNC_START + frame + SYNC_END) + } + + // ─── Input ───────────────────────────────────────────── + + sendInputToFocused(rawSequence: string) { + const pane = this.focusedPane + if (!pane) return + // Reset scroll offset when user types (back to live view) + const offset = getScrollOffset(pane.session.name) + if (offset > 0) { + scrollPane(pane.session.name, "down", offset) + this.drawChrome() + } + writeToSession(pane.session.name, rawSequence) + } + + sendScrollToFocused(direction: "up" | "down", lines = 5) { + const pane = this.focusedPane + if (!pane) return + const offset = scrollPane(pane.session.name, direction, lines) + // Update title to show scroll indicator + this.drawChrome() + } + + // ─── Status ──────────────────────────────────────────── + + markIdle(sessionName: string) { + const pane = this.panes.find(p => p.session.name === sessionName) + if (!pane) return + if (pane.status !== "idle") { pane.status = "idle"; pane.statusSince = Date.now() } + this.startFlash(sessionName) + this.drawChrome() + } + + markBusy(sessionName: string) { + const pane = this.panes.find(p => p.session.name === sessionName) + if (!pane) return + if (pane.status !== "busy") { pane.status = "busy"; pane.statusSince = Date.now() } + this.clearFlash(sessionName) + this.drawChrome() + } + + clearMark(sessionName: string) { + const pane = this.panes.find(p => p.session.name === sessionName) + if (!pane) return + pane.status = null; pane.statusSince = 0 + this.clearFlash(sessionName) + this.drawChrome() + } + + startFlash(sessionName: string) { + if (this.flashTimers.has(sessionName)) return + const timer = setInterval(() => this.drawChrome(), 400) + this.flashTimers.set(sessionName, timer) + } + + clearFlash(sessionName: string) { + const timer = this.flashTimers.get(sessionName) + if (timer) { clearInterval(timer); this.flashTimers.delete(sessionName) } + } + + // ─── Layout ──────────────────────────────────────────── + + private calcGrid(n?: number): { cols: number, rows: number } { + const count = n ?? this.panes.length + const cols = count <= 1 ? 1 : count <= 2 ? 2 : count <= 4 ? 2 : count <= 6 ? 3 : count <= 9 ? 3 : 4 + return { cols, rows: Math.ceil(count / cols) } + } + + private calcPaneRegions(count?: number): { screenX: number, screenY: number, contentW: number, contentH: number }[] { + const n = count ?? this.panes.length + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const { cols, rows } = this.calcGrid(n) + const cellW = Math.floor(termW / cols) + const cellH = Math.floor((termH - 2) / rows) + + const regions: { screenX: number, screenY: number, contentW: number, contentH: number }[] = [] + for (let i = 0; i < n; i++) { + const gc = i % cols + const gr = Math.floor(i / cols) + const contentW = cellW - 2 + const contentH = cellH - 4 + const screenX = gc * cellW + 2 + const screenY = 2 + gr * cellH + 3 + regions.push({ + screenX, + screenY, + contentW: Math.max(contentW, 10), + contentH: Math.max(contentH, 2), + }) + } + return regions + } + + repositionAll() { + if (this.isExpanded) { + // Expanded: give the expanded pane full screen area + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const contentW = termW - 2 + const contentH = termH - 2 - 4 // -2 header/footer, -4 border chrome + const pane = this.panes[this._expandedIndex]! + pane.directPane.reposition(2, 5, Math.max(contentW, 10), Math.max(contentH, 2)) + resizeSession(pane.session.name, Math.max(contentW, 10), Math.max(contentH, 2)) + resizeCapture(pane.session.name, Math.max(contentW, 10), Math.max(contentH, 2)) + resetHash(`dp_${pane.session.name}`) + } else { + const regions = this.calcPaneRegions() + for (let i = 0; i < this.panes.length; i++) { + const pane = this.panes[i]! + const region = regions[i]! + pane.directPane.reposition(region.screenX, region.screenY, region.contentW, region.contentH) + resizeSession(pane.session.name, region.contentW, region.contentH) + resizeCapture(pane.session.name, region.contentW, region.contentH) + resetHash(`dp_${pane.session.name}`) + } + } + if (this.running) { + this.writeRaw(CLEAR) + this.drawChrome() + } + } + + private refreshTitles() { + let needsDraw = false + for (const pane of this.panes) { + if (pane.status && pane.statusSince) needsDraw = true + } + if (needsDraw) this.drawChrome() + } + + destroyAll() { + this.stop() + this.panes = [] + this._focusIndex = 0 + } +} diff --git a/src/components/direct-pane.ts b/src/components/direct-pane.ts new file mode 100644 index 0000000..bb9fe12 --- /dev/null +++ b/src/components/direct-pane.ts @@ -0,0 +1,74 @@ +// Lightweight terminal pane: writes raw ANSI from PTY capture directly to stdout. +// No parsing, no FrameBuffer, no OpenTUI. Just cursor-addressed raw lines. + +import { onFrame, hasChanged, resetHash, type CaptureResult } from "../pty/capture" + +export class DirectPane { + screenX: number // 1-based screen column of content area + screenY: number // 1-based screen row of content area + width: number // content columns + height: number // content rows + sessionName = "" + + private unsub: (() => void) | null = null + + // Set by DirectGridRenderer to receive frame updates + onFrame: ((lines: string[], pane: DirectPane) => void) | null = null + + constructor(x: number, y: number, w: number, h: number) { + this.screenX = x + this.screenY = y + this.width = w + this.height = h + } + + attach(sessionName: string) { + this.detach() + this.sessionName = sessionName + resetHash(`dp_${sessionName}`) + this.unsub = onFrame(sessionName, (result) => { + if (!hasChanged(result.lines, `dp_${sessionName}`)) return + if (this.onFrame) this.onFrame(result.lines, this) + }) + } + + detach() { + if (this.unsub) { this.unsub(); this.unsub = null } + if (this.sessionName) resetHash(`dp_${this.sessionName}`) + this.sessionName = "" + this.onFrame = null + } + + reposition(x: number, y: number, w: number, h: number) { + this.screenX = x + this.screenY = y + this.width = w + this.height = h + } + + // Build cursor-addressed ANSI output for this pane's content. + // Returns a string ready for stdout.write(). No allocations beyond the string. + buildFrame(lines: string[]): string { + let out = "" + const x = this.screenX + const y = this.screenY + const w = this.width + const h = this.height + + for (let row = 0; row < h; row++) { + // Position cursor at start of this row + out += `\x1b[${y + row};${x}H` + // Erase w characters (clears old content) + out += `\x1b[${w}X` + + if (row < lines.length) { + // Write raw ANSI line from PTY (already correct width) + out += lines[row] + // Reset SGR to prevent color bleed into border + out += "\x1b[0m" + } + } + + return out + } +} diff --git a/src/components/session-grid.ts b/src/components/session-grid.ts index 0304bdd..ea6b253 100644 --- a/src/components/session-grid.ts +++ b/src/components/session-grid.ts @@ -10,13 +10,31 @@ import { } from "@opentui/core" import { TerminalView, getProjectColor } from "./terminal-view" import type { TmuxSession } from "../tmux/session-manager" -import { sendKeys } from "../tmux/input-bridge" +import { sendKeys, sendMouseEvent } from "../tmux/input-bridge" + +export type PaneStatus = "busy" | "idle" | null export interface GridPane { session: TmuxSession termView: TerminalView borderBox: BoxRenderable titleText: TextRenderable + subtitleText: TextRenderable + status: PaneStatus + statusSince: number // Date.now() when status was set +} + +function fmtElapsed(sinceMs: number): string { + if (!sinceMs) return "" + const sec = Math.floor((Date.now() - sinceMs) / 1000) + if (sec < 1) return "0s" + if (sec < 60) return `${sec}s` + const m = Math.floor(sec / 60) + const s = sec % 60 + if (m < 60) return `${m}m${s > 0 ? String(s).padStart(2, "0") + "s" : ""}` + const h = Math.floor(m / 60) + const rm = m % 60 + return `${h}h${rm > 0 ? String(rm).padStart(2, "0") + "m" : ""}` } export class SessionGrid { @@ -25,17 +43,19 @@ export class SessionGrid { private panes: GridPane[] = [] private _focusIndex = 0 private flashTimers = new Map>() + private titleTimer: ReturnType | null = null constructor(renderer: CliRenderer, container: BoxRenderable) { this.renderer = renderer this.container = container + this.titleTimer = setInterval(() => this.refreshTitles(), 1000) } get focusIndex() { return this._focusIndex } get paneCount() { return this.panes.length } get focusedPane(): GridPane | null { return this.panes[this._focusIndex] ?? null } - addSession(session: TmuxSession): GridPane { + addSession(session: TmuxSession, subtitle?: ReturnType): GridPane { const color = getProjectColor(session.colorIndex) const colorRGBA = RGBA.fromHex(color) @@ -55,18 +75,27 @@ export class SessionGrid { }) titleText.content = t` ${bold(fg(color)(session.projectName))}${session.sessionId ? dim(` #${session.sessionId.slice(0, 8)}`) : ""}` - // Calculate pane size (leave room for border + title) + const subtitleText = new TextRenderable(this.renderer, { + width: "100%", + height: 1, + flexShrink: 0, + }) + if (subtitle) subtitleText.content = subtitle + else subtitleText.content = t` ${dim("...")}` + + // Calculate pane size (leave room for border + title + subtitle) const dims = this.calcPaneDims() const termView = new TerminalView(this.renderer, { width: Math.max(dims.w - 2, 10), - height: Math.max(dims.h - 3, 4), + height: Math.max(dims.h - 4, 4), }) borderBox.add(titleText) + borderBox.add(subtitleText) borderBox.add(termView) this.container.add(borderBox) - const pane: GridPane = { session, termView, borderBox, titleText } + const pane: GridPane = { session, termView, borderBox, titleText, subtitleText, status: null, statusSince: 0 } this.panes.push(pane) termView.attach(session) @@ -114,10 +143,110 @@ export class SessionGrid { } } - async sendInputToFocused(rawSequence: string) { + flashFocused() { const pane = this.focusedPane if (!pane) return - await sendKeys(pane.session.name, rawSequence) + const flashColor = RGBA.fromHex("#7dcfff") + pane.borderBox.borderColor = flashColor + this.renderer.requestRender() + setTimeout(() => { + // Restore to heavy white (focused state) + pane.borderBox.borderColor = RGBA.fromHex("#ffffff") + this.renderer.requestRender() + }, 150) + } + + focusByDirection(dir: "up" | "down" | "left" | "right") { + const n = this.panes.length + if (n <= 1) return + const cols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4 + const rows = Math.ceil(n / cols) + const curCol = this._focusIndex % cols + const curRow = Math.floor(this._focusIndex / cols) + + let newCol = curCol + let newRow = curRow + switch (dir) { + case "left": newCol = (curCol - 1 + cols) % cols; break + case "right": newCol = (curCol + 1) % cols; break + case "up": newRow = (curRow - 1 + rows) % rows; break + case "down": newRow = (curRow + 1) % rows; break + } + const idx = newRow * cols + newCol + if (idx >= 0 && idx < n) { + this._focusIndex = idx + this.updateBorders() + } + } + + focusByClick(col: number, row: number): boolean { + const n = this.panes.length + if (n === 0) return false + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const cols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4 + const rows = Math.ceil(n / cols) + const cellW = Math.floor(termW / cols) + const cellH = Math.floor((termH - 2) / rows) // -2 for header+footer + const gridCol = Math.floor(col / cellW) + const gridRow = Math.floor((row - 1) / cellH) // -1 for header line + const idx = gridRow * cols + gridCol + if (idx >= 0 && idx < n) { + this._focusIndex = idx + this.updateBorders() + return true + } + return false + } + + sendInputToFocused(rawSequence: string) { + const pane = this.focusedPane + if (!pane) return + sendKeys(pane.session.name, rawSequence) + pane.termView.nudge() + } + + // Hit-test: map absolute screen coords to pane index + relative terminal coords + hitTest(absCol: number, absRow: number): { index: number, relX: number, relY: number } | null { + const n = this.panes.length + if (n === 0) return null + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const gridCols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4 + const gridRows = Math.ceil(n / gridCols) + const cellW = Math.floor(termW / gridCols) + const cellH = Math.floor((termH - 2) / gridRows) + + // Convert 1-based screen coords to grid cell + const gc = Math.floor((absCol - 1) / cellW) + const gr = Math.floor((absRow - 2) / cellH) // row 2 is first grid row (row 1 = header) + if (gc < 0 || gc >= gridCols || gr < 0 || gr >= gridRows) return null + + const idx = gr * gridCols + gc + if (idx < 0 || idx >= n) return null + + // Terminal content within cell: after left border(1) + after top border(1)+title(1)+subtitle(1) + const termStartX = gc * cellW + 2 // 1-based + left border + const termStartY = 2 + gr * cellH + 3 // header(1) + top border(1) + title(1) + subtitle(1) + + return { + index: idx, + relX: absCol - termStartX + 1, // 1-based for tmux + relY: absRow - termStartY + 1, + } + } + + // Forward mouse event to the focused tmux pane with correct relative coordinates + sendMouseToFocused(absCol: number, absRow: number, btn: number, release: boolean) { + const pane = this.focusedPane + if (!pane) return + + const hit = this.hitTest(absCol, absRow) + if (!hit || hit.index !== this._focusIndex) return + if (hit.relX < 1 || hit.relY < 1) return + if (hit.relX > pane.session.width || hit.relY > pane.session.height) return + + sendMouseEvent(pane.session.name, hit.relX, hit.relY, btn, release) } // Flash a pane's border to draw attention (e.g., when session goes idle) @@ -159,34 +288,69 @@ export class SessionGrid { markIdle(sessionName: string) { const pane = this.panes.find(p => p.session.name === sessionName) if (!pane) return + if (pane.status !== "idle") { + pane.status = "idle" + pane.statusSince = Date.now() + } this.startFlash(sessionName) - pane.titleText.content = t` ${bold(fg(getProjectColor(pane.session.colorIndex))(pane.session.projectName))} ${fg("#e0af68")("NEEDS INPUT")}` - this.renderer.requestRender() + this.renderPaneTitle(pane) } markBusy(sessionName: string) { const pane = this.panes.find(p => p.session.name === sessionName) if (!pane) return + if (pane.status !== "busy") { + pane.status = "busy" + pane.statusSince = Date.now() + } this.clearFlash(sessionName) - pane.titleText.content = t` ${bold(fg(getProjectColor(pane.session.colorIndex))(pane.session.projectName))} ${fg("#9ece6a")("RUNNING")}` - this.renderer.requestRender() + this.renderPaneTitle(pane) } clearMark(sessionName: string) { const pane = this.panes.find(p => p.session.name === sessionName) if (!pane) return + pane.status = null + pane.statusSince = 0 this.clearFlash(sessionName) + this.renderPaneTitle(pane) + } + + private renderPaneTitle(pane: GridPane) { const color = getProjectColor(pane.session.colorIndex) - pane.titleText.content = t` ${bold(fg(color)(pane.session.projectName))}${pane.session.sessionId ? dim(` #${pane.session.sessionId.slice(0, 8)}`) : ""}` + const name = bold(fg(color)(pane.session.projectName)) + const elapsed = pane.statusSince ? fmtElapsed(pane.statusSince) : "" + + if (pane.status === "idle") { + pane.titleText.content = t` ${name} ${fg("#e0af68")("IDLE")} ${dim(elapsed)}` + } else if (pane.status === "busy") { + pane.titleText.content = t` ${name} ${fg("#9ece6a")("RUNNING")} ${dim(elapsed)}` + } else { + pane.titleText.content = t` ${name}${pane.session.sessionId ? dim(` #${pane.session.sessionId.slice(0, 8)}`) : ""}` + } this.renderer.requestRender() } + private refreshTitles() { + let needsRender = false + for (const pane of this.panes) { + if (pane.status && pane.statusSince) { + this.renderPaneTitle(pane) + needsRender = true + } + } + if (needsRender) this.renderer.requestRender() + } + private updateBorders() { for (let i = 0; i < this.panes.length; i++) { const pane = this.panes[i] const isFocused = i === this._focusIndex const color = getProjectColor(pane.session.colorIndex) + // Update terminal view focus state (controls poll rate) + pane.termView.focused = isFocused + // Focused pane gets brighter border, others get dimmer if (isFocused) { pane.borderBox.borderColor = RGBA.fromHex("#ffffff") @@ -241,6 +405,7 @@ export class SessionGrid { } destroyAll() { + if (this.titleTimer) { clearInterval(this.titleTimer); this.titleTimer = null } for (const timer of this.flashTimers.values()) clearInterval(timer) this.flashTimers.clear() for (const pane of this.panes) { diff --git a/src/components/terminal-view.ts b/src/components/terminal-view.ts index 901077d..38e2dbf 100644 --- a/src/components/terminal-view.ts +++ b/src/components/terminal-view.ts @@ -6,7 +6,7 @@ import { RGBA, TextAttributes, } from "@opentui/core" -import { capturePane, hasChanged, resetHash } from "../tmux/capture" +import { startCapture, stopCapture, setCaptureRate, onFrame, hasChanged, resetHash, type CaptureResult } from "../tmux/capture" import { parseAnsiFrame, type ParsedFrame } from "../tmux/ansi-parser" import type { TmuxSession } from "../tmux/session-manager" @@ -28,34 +28,52 @@ export function getProjectColor(colorIndex: number): string { return PROJECT_COLORS[colorIndex % PROJECT_COLORS.length] } +// Push-based terminal view: no poll timers. +// Subscribes to capture stream and renders only when content changes. export class TerminalView extends FrameBufferRenderable { session: TmuxSession | null = null - private pollTimer: ReturnType | null = null + private unsubCapture: (() => void) | null = null private lastFrame: ParsedFrame | null = null private _focused = false - private _flashUntil = 0 // timestamp until which border flashes + private _flashUntil = 0 private _idleSince = 0 + private _frameDirty = false constructor(ctx: RenderContext, options: FrameBufferOptions) { super(ctx, options) } get focused() { return this._focused } - set focused(v: boolean) { this._focused = v } + set focused(v: boolean) { + if (this._focused === v) return + this._focused = v + // Focused pane captures at ~60fps, unfocused at ~5fps + if (this.session) { + setCaptureRate(this.session.name, v ? 16 : 200) + } + } get idleSince() { return this._idleSince } attach(session: TmuxSession) { this.detach() this.session = session - resetHash() - this.startPolling() + resetHash(session.name) + const captureMs = this._focused ? 16 : 200 + startCapture(session.name, captureMs) + // Subscribe to push notifications — no poll timer needed + this.unsubCapture = onFrame(session.name, (frame) => this.onNewFrame(frame)) } detach() { - this.stopPolling() + if (this.unsubCapture) { this.unsubCapture(); this.unsubCapture = null } + if (this.session) { + stopCapture(this.session.name) + resetHash(this.session.name) + } this.session = null this.lastFrame = null + this._frameDirty = false } flash(durationMs = 2000) { @@ -70,29 +88,19 @@ export class TerminalView extends FrameBufferRenderable { this._idleSince = 0 } - private startPolling() { - if (this.pollTimer) return - this.pollTimer = setInterval(() => this.refresh(), 80) + // Hint that input was sent — request immediate render + nudge() { + this.requestRender() } - private stopPolling() { - if (this.pollTimer) { - clearInterval(this.pollTimer) - this.pollTimer = null - } - } - - private async refresh() { + private onNewFrame(result: CaptureResult) { if (!this.session) return - - const result = await capturePane(this.session.name) - if (!result) return - - if (!hasChanged(result.lines)) return + if (!hasChanged(result.lines, this.session.name)) return const frame = parseAnsiFrame(result.lines, result.width, result.height) this.lastFrame = frame - this.renderFrameToBuffer(frame) + this._frameDirty = true + this.requestRender() } private renderFrameToBuffer(frame: ParsedFrame) { @@ -113,17 +121,20 @@ export class TerminalView extends FrameBufferRenderable { } } + // Only write to framebuffer when content actually changed (dirty flag) + // Previously this re-rendered EVERY paint cycle — major CPU waste protected renderSelf(buffer: OptimizedBuffer) { - if (this.lastFrame) { + if (this._frameDirty && this.lastFrame) { this.renderFrameToBuffer(this.lastFrame) + this._frameDirty = false } super.renderSelf(buffer) } protected onResize(width: number, height: number) { super.onResize(width, height) + this._frameDirty = true // Re-render frame to new buffer size if (this.session) { - // Resize tmux pane to match (async, fire-and-forget) import("../tmux/session-manager").then(m => { if (this.session) m.resizePane(this.session.name, width, height) }) diff --git a/src/index.ts b/src/index.ts index 584a872..468fd65 100755 --- a/src/index.ts +++ b/src/index.ts @@ -24,9 +24,9 @@ import { generateMockProjects, generateMockSessions, generateMockBranches, gener import { detectActiveSessions, updateProjectSessions, generateMockActiveSessions, focusTerminalByPath, checkTransitions, snapshotBusy, playDoneSound, bounceDock, getSessionStatus, populateMockSessionStatus, getIdleSessions } from "./data/monitor" import { getUsageSummary, formatCost, formatWindow, makeBar, pct, PLAN_LIMITS, type UsageSummary } from "./data/usage" import { launchSelections } from "./actions/launcher" -import { createSession, getSessions, refreshAlive, type TmuxSession } from "./tmux/session-manager" -import { SessionGrid } from "./components/session-grid" -import { getProjectColor } from "./components/terminal-view" +import { createSession, getSessions, refreshAlive, type PtySession } from "./pty/session-manager" +import { stopAllCaptures } from "./pty/capture" +import { DirectGridRenderer } from "./components/direct-grid" import type { Project, DisplayRow } from "./lib/types" import { timeAgo, formatSize, elapsedCompact } from "./lib/time" @@ -57,11 +57,9 @@ let cachedIdleSessions: import("./data/monitor").IdleSessionInfo[] = [] // ─── Grid Mode State ─────────────────────────────────────────────── type ViewMode = "picker" | "grid" let viewMode: ViewMode = "picker" -let sessionGrid: SessionGrid | null = null -let gridContainer: BoxRenderable | null = null -let gridHeader: TextRenderable | null = null -let gridFooter: TextRenderable | null = null +let directGrid: DirectGridRenderer | null = null let mainBox: BoxRenderable | null = null +let rawStdoutWrite: (s: string) => boolean // ─── UI Refs ──────────────────────────────────────────────────────── let renderer: CliRenderer @@ -384,7 +382,7 @@ function updateFooter() { )}` } else { footerText.content = t` ${dim( - "↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter launch │ q quit" + "↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter grid │ o external │ q quit" )}` } } @@ -518,6 +516,82 @@ function updateAll() { updateFooter() } +// ─── Mouse Parsing ────────────────────────────────────────────────── +interface MouseEvent { btn: number; col: number; row: number; release: boolean } + +function parseMouseEvent(seq: string): MouseEvent | null { + // SGR encoding: \x1b[= 6 && seq.startsWith("\x1b[M")) { + const raw = seq.charCodeAt(3) - 32 + const col = seq.charCodeAt(4) - 32 + const row = seq.charCodeAt(5) - 32 + // In legacy mode, btn 3 = release, lower 2 bits = button + const release = (raw & 3) === 3 + const btn = release ? 0 : (raw & 3) + // Scroll: bit 6 set means scroll. 64 = scroll up, 65 = scroll down + const scroll = raw & 64 + return { btn: scroll ? (raw & 67) : btn, col, row, release: release && !scroll } + } + + return null +} + +// ─── Mouse ────────────────────────────────────────────────────────── +function hitTestListRow(screenRow: number): number { + // List starts at row 2 (header=0, colHeader=1) + const listStartY = 2 + const relY = screenRow - listStartY + listBox.scrollTop + if (relY < 0) return -1 + + let y = 0 + for (let i = 0; i < displayRows.length; i++) { + const h = displayRows[i].type === "session" ? 3 : 1 + if (relY >= y && relY < y + h) return i + y += h + } + return -1 +} + +function handlePickerClick(_col: number, screenRow: number) { + const idx = hitTestListRow(screenRow) + if (idx < 0 || idx >= displayRows.length) return + + cursor = idx + const row = displayRows[idx] + const project = projects[row.projectIndex] + + // Toggle selection (same as space key) + if (row.type === "project" || row.type === "new-session") { + const path = project.path + if (selectedProjects.has(path)) selectedProjects.delete(path) + else selectedProjects.add(path) + } else if (row.type === "session") { + const session = project.sessions![row.sessionIndex!] + if (selectedSessions.has(session.id)) selectedSessions.delete(session.id) + else selectedSessions.add(session.id) + } else if (row.type === "branch") { + const path = project.path + if (selectedBranches.get(path) === row.branchName) { + selectedBranches.delete(path) + } else { + selectedBranches.set(path, row.branchName!) + } + } + + updateAll() +} + // ─── Keyboard ─────────────────────────────────────────────────────── async function handleKeypress(key: KeyEvent) { try { @@ -597,7 +671,6 @@ async function handleKeypress(key: KeyEvent) { selectedBranches.set(path, row.branchName!) } } - if (cursor < total - 1) cursor++ break } @@ -676,16 +749,36 @@ async function handleKeypress(key: KeyEvent) { break } + case "o": { + // Open selected projects in external Terminal.app windows + const hasOSel = selectedProjects.size > 0 || selectedSessions.size > 0 + if (!hasOSel) { + // If nothing selected, select current row's project + const oRow = displayRows[cursor] + if (oRow) selectedProjects.add(projects[oRow.projectIndex].path) + } + if (selectedProjects.size > 0 || selectedSessions.size > 0) { + await launchSelections(projects, selectedProjects, selectedSessions, selectedBranches) + selectedProjects.clear() + selectedSessions.clear() + selectedBranches.clear() + } + break + } + case "q": case "escape": destroyed = true if (monitorInterval) clearInterval(monitorInterval) + stopAllCaptures() + process.stdout.write("\x1b[?1006l") + process.stdout.write("\x1b[?1000l") renderer.destroy() return case "t": - // Switch to grid view if there are tmux sessions - if (sessionGrid && sessionGrid.paneCount > 0) { + // Switch to grid view if there are panes + if (directGrid && directGrid.paneCount > 0) { switchToGrid() return } @@ -751,7 +844,13 @@ async function doLaunch() { if (!project) continue const targetBranch = selectedBranches.get(path) const needsBranch = targetBranch && targetBranch !== project.branch - items.push({ path, name: project.name, targetBranch: needsBranch ? targetBranch : undefined }) + // Auto-resume most recent session (loaded lazily if needed) + if (!project.sessions) { + project.sessions = await loadSessions(project.path) + project.sessionCount = project.sessions.length + } + const lastSessionId = project.sessions[0]?.id + items.push({ path, name: project.name, sessionId: lastSessionId, targetBranch: needsBranch ? targetBranch : undefined }) } for (const project of projects) { @@ -767,16 +866,17 @@ async function doLaunch() { if (items.length === 0) return - // Create tmux sessions and switch to grid view + // Create PTY sessions and switch to grid view ensureGridView() + // DirectGridRenderer calculates pane sizes internally const termW = process.stdout.columns || 120 const termH = process.stdout.rows || 40 - const n = items.length + (sessionGrid?.paneCount || 0) - const cols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : 3 - const rows = Math.ceil(n / cols) - const paneW = Math.floor(termW / cols) - 2 - const paneH = Math.floor((termH - 4) / rows) - 3 + const totalPanes = items.length + (directGrid?.paneCount || 0) + const cols = totalPanes <= 1 ? 1 : totalPanes <= 2 ? 2 : totalPanes <= 4 ? 2 : 3 + const rows = Math.ceil(totalPanes / cols) + const paneW = Math.max(Math.floor(termW / cols) - 2, 20) + const paneH = Math.max(Math.floor((termH - 2) / rows) - 4, 6) for (const item of items) { const session = await createSession({ @@ -784,102 +884,76 @@ async function doLaunch() { projectName: item.name, sessionId: item.sessionId, targetBranch: item.targetBranch, - width: Math.max(paneW, 20), - height: Math.max(paneH, 6), + width: paneW, + height: paneH, }) - sessionGrid!.addSession(session) + await directGrid!.addPane(session) } selectedProjects.clear() selectedSessions.clear() selectedBranches.clear() - updateGridHeader() - updateGridFooter() - renderer.requestRender() } -// ─── Grid View ───────────────────────────────────────────────────── +// ─── Grid View (Direct Renderer) ──────────────────────────────────── function ensureGridView() { - if (viewMode === "grid" && sessionGrid) return + if (viewMode === "grid" && directGrid) return switchToGrid() } function switchToGrid() { viewMode = "grid" - if (mainBox) mainBox.visible = false - if (!gridContainer) { - gridHeader = new TextRenderable(renderer, { - width: "100%", - height: 1, - flexShrink: 0, - }) - - gridContainer = new BoxRenderable(renderer, { - flexDirection: "row", - flexWrap: "wrap", - flexGrow: 1, - width: "100%", - overflow: "hidden", - }) - - gridFooter = new TextRenderable(renderer, { - width: "100%", - height: 1, - flexShrink: 0, - }) - - const gridRoot = new BoxRenderable(renderer, { - flexDirection: "column", - width: "100%", - height: "100%", - }) - gridRoot.add(gridHeader!) - gridRoot.add(gridContainer!) - gridRoot.add(gridFooter!) - renderer.root.add(gridRoot) - - sessionGrid = new SessionGrid(renderer, gridContainer) + if (!directGrid) { + directGrid = new DirectGridRenderer(rawStdoutWrite) } - if (gridContainer) gridContainer.visible = true - if (gridHeader) gridHeader.visible = true - if (gridFooter) gridFooter.visible = true - updateGridHeader() - updateGridFooter() - renderer.requestRender() + // Suspend OpenTUI render loop — this pauses stdin, exits raw mode, disables mouse + renderer.suspend() + + // Restore terminal state for direct rendering + if (process.stdin.isTTY) process.stdin.setRawMode(true) + process.stdin.resume() // Re-enable stdin data events (suspend pauses them) + rawStdoutWrite("\x1b[?1049h") // Alternate screen + // Enable mouse reporting in grid mode — only for scroll wheel and click-to-focus. + // With direct PTY (no tmux), mouse events stay in our stdin handler and never leak to subprocesses. + rawStdoutWrite("\x1b[?1000h") // Basic mouse tracking (clicks + scroll) + rawStdoutWrite("\x1b[?1006h") // SGR extended mouse coordinates + directGrid.start() } function switchToPicker() { viewMode = "picker" + if (directGrid) { + if (directGrid.selectMode) directGrid.exitSelectMode() + if (directGrid.paneCount > 0) directGrid.stop() + } + // Resume OpenTUI — it will re-enter alternate screen and redraw + renderer.resume() + // Remove any data listeners OpenTUI re-adds during resume (we handle stdin ourselves) + process.stdin.removeAllListeners("data") + process.stdin.on("data", stdinHandler) + // Re-enable mouse reporting (resume may not restore it) + process.stdout.write("\x1b[?1000h") + process.stdout.write("\x1b[?1006h") if (mainBox) mainBox.visible = true - if (gridContainer) gridContainer.visible = false - if (gridHeader) gridHeader.visible = false - if (gridFooter) gridFooter.visible = false updateAll() renderer.requestRender() } -function updateGridHeader() { - if (!gridHeader) return - const n = sessionGrid?.paneCount || 0 - const fi = (sessionGrid?.focusIndex ?? 0) + 1 - gridHeader.content = t` ${bold("cladm grid")} — ${String(n)} sessions │ focus: ${String(fi)}/${String(n)} ${dim("ctrl+` picker │ ctrl+n/p switch │ ctrl+w close")}` -} - -function updateGridFooter() { - if (!gridFooter || !sessionGrid) return - const pane = sessionGrid.focusedPane - if (pane) { - const color = getProjectColor(pane.session.colorIndex) - gridFooter.content = t` ${fg(color)("▸")} ${bold(pane.session.projectName)}${pane.session.sessionId ? dim(` #${pane.session.sessionId.slice(0, 8)}`) : ""} ${dim("all input goes to focused pane")}` - } else { - gridFooter.content = t` ${dim("No sessions. Press ctrl+\` to return to picker.")}` - } +function resizeGridPanes() { + if (!directGrid || directGrid.paneCount === 0) return + directGrid.repositionAll() } async function handleGridInput(rawSequence: string): Promise { - if (viewMode !== "grid") return false + if (viewMode !== "grid" || !directGrid) return false + + // Esc — collapse expanded pane back to grid + if (rawSequence === "\x1b" && directGrid.isExpanded) { + directGrid.collapsePane() + return true + } // Ctrl+` (0x1e) or ESC+` — return to picker if (rawSequence === "\x1e" || rawSequence === "\x1b`") { @@ -889,40 +963,50 @@ async function handleGridInput(rawSequence: string): Promise { // Ctrl+N — focus next pane if (rawSequence === "\x0e") { - sessionGrid?.focusNext() - updateGridHeader() - updateGridFooter() + directGrid.focusNext() return true } // Ctrl+P — focus previous pane if (rawSequence === "\x10") { - sessionGrid?.focusPrev() - updateGridHeader() - updateGridFooter() + directGrid.focusPrev() + return true + } + + // Ctrl+F — open focused pane's project in Finder + if (rawSequence === "\x06") { + const pane = directGrid.focusedPane + if (pane) Bun.spawn(["open", pane.session.projectPath]) return true } // Ctrl+W — close focused pane if (rawSequence === "\x17") { - const pane = sessionGrid?.focusedPane + const pane = directGrid.focusedPane if (pane) { - const { killSession } = await import("./tmux/session-manager") - sessionGrid!.removeSession(pane.session.name) + if (directGrid.isExpanded) directGrid.collapsePane() + const { killSession } = await import("./pty/session-manager") + directGrid.removePane(pane.session.name) await killSession(pane.session.name) - updateGridHeader() - updateGridFooter() - if (sessionGrid!.paneCount === 0) { + if (directGrid.paneCount === 0) { switchToPicker() } } return true } - // Forward everything else to the focused tmux pane - if (sessionGrid) { - await sessionGrid.sendInputToFocused(rawSequence) + // PageUp / PageDown (Fn+Up/Down on Mac) — scroll focused pane + if (rawSequence === "\x1b[5~") { + directGrid.sendScrollToFocused("up") + return true } + if (rawSequence === "\x1b[6~") { + directGrid.sendScrollToFocused("down") + return true + } + + // Forward everything else to the focused PTY pane + directGrid.sendInputToFocused(rawSequence) return true } @@ -950,16 +1034,25 @@ async function main() { sortedIndices = projects.map((_, i) => i) rebuildDisplayRows() + // Save raw stdout.write BEFORE OpenTUI intercepts it + rawStdoutWrite = process.stdout.write.bind(process.stdout) as (s: string) => boolean + renderer = await createCliRenderer({ exitOnCtrlC: true, useAlternateScreen: true, - useMouse: true, + useMouse: false, // We handle mouse ourselves to avoid OpenTUI consuming events onDestroy: () => { destroyed = true if (monitorInterval) { clearInterval(monitorInterval); monitorInterval = null } + if (directGrid) directGrid.destroyAll() + stopAllCaptures() }, }) + // Enable mouse reporting manually (SGR mode for full coordinates) + process.stdout.write("\x1b[?1000h") // Basic click tracking + process.stdout.write("\x1b[?1006h") // SGR extended coordinates + // Build layout mainBox = new BoxRenderable(renderer, { flexDirection: "column", @@ -1055,15 +1148,273 @@ async function main() { updateUsagePanel() }).catch(() => {}) - // Intercept raw input for grid mode (before OpenTUI processes it) - renderer.prependInputHandler((sequence: string) => { - if (viewMode !== "grid") return false - // Handle grid input asynchronously, consume the event - handleGridInput(sequence) - return true + // Resize PTY panes when terminal window is resized + process.stdout.on("resize", () => { + if (viewMode !== "grid" || !directGrid) return + resizeGridPanes() }) - renderer.keyInput.on("keypress", handleKeypress) + // Shift+arrow sequences across terminal emulators + const SHIFT_ARROWS: Record = { + "\x1b[1;2A": "up", // xterm/iTerm2/most terminals + "\x1b[1;2B": "down", + "\x1b[1;2C": "right", + "\x1b[1;2D": "left", + "\x1b[a": "up", // rxvt + "\x1b[b": "down", + "\x1b[c": "right", + "\x1b[d": "left", + } + + // Take over stdin completely — removes OpenTUI's data listeners + // We parse all keys ourselves to avoid double-processing issues + process.stdin.removeAllListeners("data") + + // Extract only safe keyboard input from stdin data. + // WHITELIST approach: only recognized keyboard sequences pass through. + // Everything else (mouse events, terminal responses, OSC, DCS, etc.) is dropped. + function extractKeyboardInput(data: string): string { + let keyboard = "" + let i = 0 + + while (i < data.length) { + const c = data.charCodeAt(i) + + // ESC sequences + if (c === 0x1b) { + if (i + 1 >= data.length) { keyboard += "\x1b"; i++; continue } // lone ESC = Escape key + + const next = data[i + 1] + + // OSC: \x1b] ... (terminated by BEL \x07 or ST \x1b\\) — drop entirely + if (next === "]") { + let j = i + 2 + while (j < data.length) { + if (data[j] === "\x07") { j++; break } + if (data[j] === "\x1b" && j + 1 < data.length && data[j + 1] === "\\") { j += 2; break } + j++ + } + i = j; continue + } + + // DCS: \x1bP ... ST | APC: \x1b_ ... ST | PM: \x1b^ ... ST — drop entirely + if (next === "P" || next === "_" || next === "^") { + let j = i + 2 + while (j < data.length) { + if (data[j] === "\x1b" && j + 1 < data.length && data[j + 1] === "\\") { j += 2; break } + j++ + } + i = j; continue + } + + // CSI: \x1b[ + if (next === "[") { + let j = i + 2 + // Consume parameter bytes (0x30-0x3F: digits, ;, <, =, >, ?) + while (j < data.length && data.charCodeAt(j) >= 0x30 && data.charCodeAt(j) <= 0x3F) j++ + // Consume intermediate bytes (0x20-0x2F) + while (j < data.length && data.charCodeAt(j) >= 0x20 && data.charCodeAt(j) <= 0x2F) j++ + // Final byte (0x40-0x7E) + if (j < data.length && data.charCodeAt(j) >= 0x40 && data.charCodeAt(j) <= 0x7E) { + const final = data[j] + // Legacy X10 mouse: \x1b[M followed by 3 raw bytes (btn+32, col+32, row+32) + // Must consume the 3 payload bytes or they leak as keyboard input + // (btn+32 for left click = 0x20 = ASCII space!) + if (final === "M" && j === i + 2) { + i = Math.min(j + 4, data.length); continue + } + // ONLY keep: arrows (A-D), Home (H), End (F), shift-tab (Z), function keys (~) + if ("ABCDHFZ~".includes(final)) { + keyboard += data.slice(i, j + 1) + } + i = j + 1; continue + } + // Incomplete/malformed CSI — drop + i = j; continue + } + + // SS3: \x1bO + letter (F1-F4, keypad) + if (next === "O" && i + 2 < data.length) { + keyboard += data.slice(i, i + 3) + i += 3; continue + } + + // \x1b` (ctrl+backtick) — keep as keyboard shortcut + if (next === "`") { + keyboard += "\x1b`" + i += 2; continue + } + + // Any other \x1b+char — drop (unknown escape sequence) + i += 2; continue + } + + // Regular character: printable ASCII, control chars, UTF-8 — keep + keyboard += data[i] + i++ + } + + return keyboard + } + + // Parse SGR mouse events from raw data. Returns array of {btn, col, row, release, consumed}. + function extractMouseEvents(data: string): { btn: number, col: number, row: number, release: boolean, start: number, end: number }[] { + const events: { btn: number, col: number, row: number, release: boolean, start: number, end: number }[] = [] + const re = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g + let m + while ((m = re.exec(data)) !== null) { + events.push({ + btn: parseInt(m[1]), + col: parseInt(m[2]), + row: parseInt(m[3]), + release: m[4] === "m", + start: m.index, + end: m.index + m[0].length, + }) + } + return events + } + + function stdinHandler(data: string | Buffer) { + const str = typeof data === "string" ? data : data.toString("utf8") + + if (viewMode === "grid" && directGrid) { + // In select mode, only listen for Esc to exit — everything else is native terminal + if (directGrid.selectMode) { + const keyboard = extractKeyboardInput(str) + if (keyboard === "\x1b") { + directGrid.exitSelectMode() + } + return + } + + // Handle mouse events first (scroll wheel + click-to-focus) + const mouseEvents = extractMouseEvents(str) + for (const me of mouseEvents) { + // Scroll: btn 64 = scroll up, btn 65 = scroll down + if (me.btn === 64) { + directGrid.sendScrollToFocused("up", 3) + continue + } + if (me.btn === 65) { + directGrid.sendScrollToFocused("down", 3) + continue + } + // Click (btn 0, press only): check buttons first, then focus pane + if (me.btn === 0 && !me.release) { + const btn = directGrid.checkButtonClick(me.col, me.row) + if (btn?.action === "max") { + directGrid.expandPane(btn.paneIndex) + } else if (btn?.action === "min") { + directGrid.collapsePane() + } else if (btn?.action === "sel") { + directGrid.enterSelectMode() + } else { + directGrid.focusByClick(me.col, me.row) + } + continue + } + } + + // Strip mouse events from the data before keyboard filtering + let stripped = str + // Remove mouse events from end to start to preserve indices + for (let i = mouseEvents.length - 1; i >= 0; i--) { + const me = mouseEvents[i] + stripped = stripped.slice(0, me.start) + stripped.slice(me.end) + } + + const keyboard = extractKeyboardInput(stripped) + if (keyboard) { + const dir = SHIFT_ARROWS[keyboard] + if (dir) { + directGrid.focusByDirection(dir) + } else { + handleGridInput(keyboard) + } + } + return + } + + // Picker mode: handle mouse clicks (scroll + click-to-select) + const pickerMouse = extractMouseEvents(str) + for (const me of pickerMouse) { + if (me.btn === 0 && !me.release) handlePickerClick(me.col, me.row) + if (me.btn === 64) { if (cursor > 0) { cursor--; updateAll() } } + if (me.btn === 65) { if (cursor < displayRows.length - 1) { cursor++; updateAll() } } + } + + // Parse keys directly (bypass OpenTUI pipeline to avoid double-firing) + const keyboard = extractKeyboardInput(str) + if (!keyboard) return + + // Map raw sequences to KeyEvent-like objects and call handleKeypress directly + const keyMap: Record = { + "\x1b[A": { name: "up" }, + "\x1b[B": { name: "down" }, + "\x1b[C": { name: "right" }, + "\x1b[D": { name: "left" }, + "\x1b[5~": { name: "pageup" }, + "\x1b[6~": { name: "pagedown" }, + "\x1b[H": { name: "home" }, + "\x1b[F": { name: "end" }, + "\x1bOH": { name: "home" }, + "\x1bOF": { name: "end" }, + "\x1b[Z": { name: "tab", shift: true }, + "\x1b[1;2A": { name: "up", shift: true }, + "\x1b[1;2B": { name: "down", shift: true }, + "\x1b[1;2C": { name: "right", shift: true }, + "\x1b[1;2D": { name: "left", shift: true }, + "\x09": { name: "tab" }, + "\x0d": { name: "return" }, + "\x1b": { name: "escape" }, + " ": { name: "space" }, + } + + // Parse sequences from the keyboard string + let ki = 0 + while (ki < keyboard.length) { + let matched = false + // Try longest match first (up to 8 chars for CSI sequences) + for (let len = Math.min(8, keyboard.length - ki); len >= 1; len--) { + const seq = keyboard.slice(ki, ki + len) + const mapped = keyMap[seq] + if (mapped) { + const syntheticKey = { + name: mapped.name, + shift: mapped.shift || false, + ctrl: mapped.ctrl || false, + meta: false, + preventDefault: () => {}, + stopPropagation: () => {}, + } as KeyEvent + handleKeypress(syntheticKey) + ki += len + matched = true + break + } + } + if (!matched) { + // Single printable character + const ch = keyboard[ki] + const code = ch.charCodeAt(0) + // Map printable ASCII to key name + if (code >= 0x21 && code <= 0x7e) { + const syntheticKey = { + name: ch, + shift: false, + ctrl: false, + meta: false, + preventDefault: () => {}, + stopPropagation: () => {}, + } as KeyEvent + handleKeypress(syntheticKey) + } + ki++ + } + } + } + process.stdin.on("data", stdinHandler) // Live session monitoring if (demoMode) { @@ -1137,14 +1488,13 @@ async function main() { if (changed) updateAll() // Update grid pane statuses (flash idle sessions) - if (sessionGrid && viewMode === "grid") { + if (directGrid && viewMode === "grid") { await refreshAlive() for (const [, s] of getSessions()) { - // Use monitor.ts to check busy/idle const status = getSessionStatus(s.projectPath, s.sessionId) - if (status === "idle") sessionGrid.markIdle(s.name) - else if (status === "busy") sessionGrid.markBusy(s.name) - else sessionGrid.clearMark(s.name) + if (status === "idle") directGrid.markIdle(s.name) + else if (status === "busy") directGrid.markBusy(s.name) + else directGrid.clearMark(s.name) } } } diff --git a/src/pty/capture.ts b/src/pty/capture.ts new file mode 100644 index 0000000..7c3bdd4 --- /dev/null +++ b/src/pty/capture.ts @@ -0,0 +1,691 @@ +// PTY capture: reads raw ANSI output from pty-helper stdout, +// maintains a virtual screen buffer, and pushes frame updates to subscribers. +// Replaces tmux capture-pane — no screen-scraping, direct PTY output. + +import type { PtySession } from "./session-manager" + +export interface CaptureResult { + lines: string[] + cursorX: number + cursorY: number + width: number + height: number +} + +type FrameCallback = (frame: CaptureResult) => void + +interface PaneState { + session: PtySession + screen: VtScreen + callbacks: Set + reader: ReadableStreamDefaultReader | null + running: boolean +} + +const panes = new Map() + +// ─── VT Screen Buffer ───────────────────────────────────────── + +interface VtCell { + char: string + sgr: string // accumulated SGR state as ANSI escape string +} + +class VtScreen { + width: number + height: number + cursorX = 0 + cursorY = 0 + cells: VtCell[][] + scrollback: VtCell[][] = [] // lines that scrolled off the top + scrollOffset = 0 // 0 = live view, >0 = scrolled back N lines + private static MAX_SCROLLBACK = 5000 + private currentSgr = "" + private savedCursorX = 0 + private savedCursorY = 0 + private scrollTop = 0 + private scrollBottom: number + private altScreen: VtCell[][] | null = null + private altCursorX = 0 + private altCursorY = 0 + + constructor(width: number, height: number) { + this.width = width + this.height = height + this.scrollBottom = height - 1 + this.cells = this.makeGrid(width, height) + } + + private makeGrid(w: number, h: number): VtCell[][] { + return Array.from({ length: h }, () => + Array.from({ length: w }, () => ({ char: " ", sgr: "" })) + ) + } + + resize(width: number, height: number) { + const newCells = this.makeGrid(width, height) + for (let r = 0; r < Math.min(height, this.height); r++) { + for (let c = 0; c < Math.min(width, this.width); c++) { + newCells[r][c] = this.cells[r]?.[c] ?? { char: " ", sgr: "" } + } + } + this.cells = newCells + this.width = width + this.height = height + this.scrollTop = 0 + this.scrollBottom = height - 1 + if (this.cursorX >= width) this.cursorX = width - 1 + if (this.cursorY >= height) this.cursorY = height - 1 + } + + // Write raw PTY output to the virtual screen + write(data: string) { + let i = 0 + while (i < data.length) { + const c = data.charCodeAt(i) + + // ESC sequences + if (c === 0x1b && i + 1 < data.length) { + const next = data[i + 1] + + // CSI: \x1b[ + if (next === "[") { + i = this.handleCSI(data, i + 2) + continue + } + + // OSC: \x1b] ... BEL or ST — skip + if (next === "]") { + i = this.skipOSC(data, i + 2) + continue + } + + // DCS/APC/PM: \x1bP, \x1b_, \x1b^ — skip to ST + if (next === "P" || next === "_" || next === "^") { + i = this.skipToST(data, i + 2) + continue + } + + // SS3: \x1bO — skip the next char + if (next === "O" && i + 2 < data.length) { + i += 3 + continue + } + + // Save cursor: \x1b7 or \x1b[s + if (next === "7") { + this.savedCursorX = this.cursorX + this.savedCursorY = this.cursorY + i += 2; continue + } + + // Restore cursor: \x1b8 or \x1b[u + if (next === "8") { + this.cursorX = this.savedCursorX + this.cursorY = this.savedCursorY + i += 2; continue + } + + // Index (scroll up): \x1bD + if (next === "D") { + this.index() + i += 2; continue + } + + // Reverse index (scroll down): \x1bM + if (next === "M") { + this.reverseIndex() + i += 2; continue + } + + // Set tab stop, reset: skip + if (next === "H" || next === "c") { + i += 2; continue + } + + // Unknown ESC — skip + i += 2; continue + } + + // C0 control characters + if (c === 0x0d) { // CR + this.cursorX = 0 + i++; continue + } + if (c === 0x0a) { // LF + if (this.cursorY === this.scrollBottom) { + this.scrollUp() + } else if (this.cursorY < this.height - 1) { + this.cursorY++ + } + i++; continue + } + if (c === 0x08) { // BS + if (this.cursorX > 0) this.cursorX-- + i++; continue + } + if (c === 0x09) { // TAB + this.cursorX = Math.min(((this.cursorX >> 3) + 1) << 3, this.width - 1) + i++; continue + } + if (c === 0x07) { // BEL + i++; continue + } + if (c < 0x20 && c !== 0x1b) { + i++; continue + } + + // Printable character + if (this.cursorX >= this.width) { + // Auto-wrap + this.cursorX = 0 + if (this.cursorY === this.scrollBottom) { + this.scrollUp() + } else if (this.cursorY < this.height - 1) { + this.cursorY++ + } + } + + const row = this.cells[this.cursorY] + if (row && this.cursorX < this.width) { + row[this.cursorX] = { char: data[i], sgr: this.currentSgr } + } + this.cursorX++ + i++ + } + } + + // Get screen as lines with embedded ANSI SGR codes (like tmux capture-pane -e) + // When scrollOffset > 0, shows scrollback history mixed with screen content + getLines(): string[] { + const lines: string[] = [] + + if (this.scrollOffset > 0) { + // Viewing scrollback: combine scrollback + current screen, then take a window + const sbLen = this.scrollback.length + const totalRows = sbLen + this.height + const startRow = totalRows - this.height - this.scrollOffset + for (let r = 0; r < this.height; r++) { + const srcRow = startRow + r + let row: VtCell[] + if (srcRow < 0) { + // Beyond scrollback — empty line + lines.push("") + continue + } else if (srcRow < sbLen) { + row = this.scrollback[srcRow] + } else { + row = this.cells[srcRow - sbLen] + } + lines.push(this.renderRow(row)) + } + } else { + // Live view: just render current screen + for (let r = 0; r < this.height; r++) { + lines.push(this.renderRow(this.cells[r])) + } + } + + return lines + } + + private renderRow(row: VtCell[]): string { + if (!row) return "" + let line = "" + let lastSgr = "" + let trailingSpaces = 0 + + for (let c = 0; c < this.width && c < row.length; c++) { + const cell = row[c] + if (cell.sgr !== lastSgr) { + if (trailingSpaces > 0) { + line += " ".repeat(trailingSpaces) + trailingSpaces = 0 + } + line += cell.sgr ? `\x1b[${cell.sgr}m` : "\x1b[0m" + lastSgr = cell.sgr + } + if (cell.char === " " && !cell.sgr) { + trailingSpaces++ + } else { + if (trailingSpaces > 0) { + line += " ".repeat(trailingSpaces) + trailingSpaces = 0 + } + line += cell.char + } + } + if (lastSgr) line += "\x1b[0m" + return line + } + + // ─── CSI Handler ────────────────────────────────────────── + + private handleCSI(data: string, start: number): number { + let i = start + const params: number[] = [] + let num = "" + let privateMode = "" + + // Check for private mode prefix (?, >, =) + if (i < data.length && (data[i] === "?" || data[i] === ">" || data[i] === "=")) { + privateMode = data[i] + i++ + } + + // Parse parameters + while (i < data.length) { + const ch = data[i] + if (ch >= "0" && ch <= "9") { + num += ch; i++ + } else if (ch === ";") { + params.push(num === "" ? 0 : parseInt(num)) + num = ""; i++ + } else { + params.push(num === "" ? 0 : parseInt(num)) + break + } + } + + if (i >= data.length) return i + + const final = data[i] + i++ + + // Private mode sequences (h/l for set/reset) + if (privateMode === "?") { + if (final === "h" || final === "l") { + const mode = params[0] ?? 0 + if (mode === 1049 || mode === 47 || mode === 1047) { + if (final === "h") { + // Enter alternate screen + this.altScreen = this.cells + this.altCursorX = this.cursorX + this.altCursorY = this.cursorY + this.cells = this.makeGrid(this.width, this.height) + this.cursorX = 0 + this.cursorY = 0 + } else { + // Leave alternate screen + if (this.altScreen) { + this.cells = this.altScreen + this.cursorX = this.altCursorX + this.cursorY = this.altCursorY + this.altScreen = null + } + } + } + // Ignore other private modes (cursor visibility, mouse, etc.) + } + return i + } + + // Standard CSI sequences + switch (final) { + case "m": // SGR + this.currentSgr = this.buildSgrString(params) + break + + case "H": case "f": { // Cursor position + const row = Math.max(0, (params[0] || 1) - 1) + const col = Math.max(0, (params[1] || 1) - 1) + this.cursorY = Math.min(row, this.height - 1) + this.cursorX = Math.min(col, this.width - 1) + break + } + + case "A": // Cursor up + this.cursorY = Math.max(0, this.cursorY - (params[0] || 1)) + break + case "B": // Cursor down + this.cursorY = Math.min(this.height - 1, this.cursorY + (params[0] || 1)) + break + case "C": // Cursor right + this.cursorX = Math.min(this.width - 1, this.cursorX + (params[0] || 1)) + break + case "D": // Cursor left + this.cursorX = Math.max(0, this.cursorX - (params[0] || 1)) + break + + case "G": // Cursor horizontal absolute + this.cursorX = Math.min(Math.max(0, (params[0] || 1) - 1), this.width - 1) + break + case "d": // Cursor vertical absolute + this.cursorY = Math.min(Math.max(0, (params[0] || 1) - 1), this.height - 1) + break + + case "J": { // Erase in display + const mode = params[0] || 0 + if (mode === 0) { // Erase below + this.clearRange(this.cursorY, this.cursorX, this.height - 1, this.width - 1) + } else if (mode === 1) { // Erase above + this.clearRange(0, 0, this.cursorY, this.cursorX) + } else if (mode === 2 || mode === 3) { // Erase all + this.cells = this.makeGrid(this.width, this.height) + } + break + } + + case "K": { // Erase in line + const mode = params[0] || 0 + const row = this.cells[this.cursorY] + if (!row) break + if (mode === 0) { // Erase to right + for (let c = this.cursorX; c < this.width; c++) row[c] = { char: " ", sgr: "" } + } else if (mode === 1) { // Erase to left + for (let c = 0; c <= this.cursorX; c++) row[c] = { char: " ", sgr: "" } + } else if (mode === 2) { // Erase entire line + for (let c = 0; c < this.width; c++) row[c] = { char: " ", sgr: "" } + } + break + } + + case "X": { // Erase characters + const n = params[0] || 1 + const row = this.cells[this.cursorY] + if (row) { + for (let c = this.cursorX; c < Math.min(this.cursorX + n, this.width); c++) { + row[c] = { char: " ", sgr: "" } + } + } + break + } + + case "L": { // Insert lines + const n = Math.min(params[0] || 1, this.scrollBottom - this.cursorY + 1) + for (let j = 0; j < n; j++) { + this.cells.splice(this.scrollBottom, 1) + this.cells.splice(this.cursorY, 0, + Array.from({ length: this.width }, () => ({ char: " ", sgr: "" }))) + } + break + } + + case "M": { // Delete lines + const n = Math.min(params[0] || 1, this.scrollBottom - this.cursorY + 1) + for (let j = 0; j < n; j++) { + this.cells.splice(this.cursorY, 1) + this.cells.splice(this.scrollBottom, 0, + Array.from({ length: this.width }, () => ({ char: " ", sgr: "" }))) + } + break + } + + case "P": { // Delete characters + const n = params[0] || 1 + const row = this.cells[this.cursorY] + if (row) { + row.splice(this.cursorX, n) + while (row.length < this.width) row.push({ char: " ", sgr: "" }) + } + break + } + + case "@": { // Insert characters + const n = params[0] || 1 + const row = this.cells[this.cursorY] + if (row) { + for (let j = 0; j < n; j++) { + row.splice(this.cursorX, 0, { char: " ", sgr: "" }) + } + row.length = this.width + } + break + } + + case "S": { // Scroll up + const n = params[0] || 1 + for (let j = 0; j < n; j++) this.scrollUp() + break + } + + case "T": { // Scroll down + const n = params[0] || 1 + for (let j = 0; j < n; j++) this.reverseIndex() + break + } + + case "r": { // Set scroll region + this.scrollTop = Math.max(0, (params[0] || 1) - 1) + this.scrollBottom = Math.min(this.height - 1, (params[1] || this.height) - 1) + this.cursorX = 0 + this.cursorY = 0 + break + } + + case "s": // Save cursor + this.savedCursorX = this.cursorX + this.savedCursorY = this.cursorY + break + case "u": // Restore cursor + this.cursorX = this.savedCursorX + this.cursorY = this.savedCursorY + break + + case "n": // Device status report — ignore + case "c": // Device attributes — ignore + case "h": case "l": // Set/reset mode — ignore + case "t": // Window manipulation — ignore + break + } + + return i + } + + private buildSgrString(params: number[]): string { + // Reset + if (params.length === 1 && params[0] === 0) return "" + if (params.length === 0) return "" + return params.join(";") + } + + private clearRange(r1: number, c1: number, r2: number, c2: number) { + for (let r = r1; r <= r2 && r < this.height; r++) { + const row = this.cells[r] + if (!row) continue + const startC = r === r1 ? c1 : 0 + const endC = r === r2 ? c2 : this.width - 1 + for (let c = startC; c <= endC && c < this.width; c++) { + row[c] = { char: " ", sgr: "" } + } + } + } + + private scrollUp() { + // Save the line scrolling off the top into scrollback + const removedRow = this.cells.splice(this.scrollTop, 1)[0] + if (removedRow && this.scrollTop === 0) { + this.scrollback.push(removedRow) + if (this.scrollback.length > VtScreen.MAX_SCROLLBACK) { + this.scrollback.shift() + } + } + this.cells.splice(this.scrollBottom, 0, + Array.from({ length: this.width }, () => ({ char: " ", sgr: "" }))) + // Auto-reset scroll offset when new output arrives + if (this.scrollOffset > 0) this.scrollOffset = 0 + } + + private index() { + if (this.cursorY === this.scrollBottom) { + this.scrollUp() + } else if (this.cursorY < this.height - 1) { + this.cursorY++ + } + } + + private reverseIndex() { + if (this.cursorY === this.scrollTop) { + this.cells.splice(this.scrollBottom, 1) + this.cells.splice(this.scrollTop, 0, + Array.from({ length: this.width }, () => ({ char: " ", sgr: "" }))) + } else if (this.cursorY > 0) { + this.cursorY-- + } + } + + private skipOSC(data: string, start: number): number { + let i = start + while (i < data.length) { + if (data[i] === "\x07") return i + 1 + if (data[i] === "\x1b" && i + 1 < data.length && data[i + 1] === "\\") return i + 2 + i++ + } + return i + } + + private skipToST(data: string, start: number): number { + let i = start + while (i < data.length) { + if (data[i] === "\x1b" && i + 1 < data.length && data[i + 1] === "\\") return i + 2 + i++ + } + return i + } +} + +// ─── Public API ───────────────────────────────────────────── + +export function startCapture(session: PtySession): void { + if (panes.has(session.name)) return + + const screen = new VtScreen(session.width, session.height) + const state: PaneState = { + session, + screen, + callbacks: new Set(), + reader: null, + running: true, + } + panes.set(session.name, state) + + // Start reading stdout from pty-helper + if (session.proc.stdout) { + const reader = session.proc.stdout.getReader() + state.reader = reader + const decoder = new TextDecoder() + + ;(async () => { + try { + while (state.running) { + const { done, value } = await reader.read() + if (done) break + const text = decoder.decode(value, { stream: true }) + screen.write(text) + + // Push frame to subscribers + const frame: CaptureResult = { + lines: screen.getLines(), + cursorX: screen.cursorX, + cursorY: screen.cursorY, + width: screen.width, + height: screen.height, + } + for (const cb of state.callbacks) { + try { cb(frame) } catch {} + } + } + } catch { + // Process died + } + })() + } +} + +export function onFrame(sessionName: string, cb: FrameCallback): () => void { + const state = panes.get(sessionName) + if (state) state.callbacks.add(cb) + return () => { + const s = panes.get(sessionName) + if (s) s.callbacks.delete(cb) + } +} + +export function getLatestFrame(sessionName: string): CaptureResult | null { + const state = panes.get(sessionName) + if (!state) return null + return { + lines: state.screen.getLines(), + cursorX: state.screen.cursorX, + cursorY: state.screen.cursorY, + width: state.screen.width, + height: state.screen.height, + } +} + +export function stopCapture(sessionName: string): void { + const state = panes.get(sessionName) + if (!state) return + state.running = false + state.callbacks.clear() + if (state.reader) { + try { state.reader.cancel() } catch {} + } + panes.delete(sessionName) +} + +export function resizeCapture(sessionName: string, width: number, height: number): void { + const state = panes.get(sessionName) + if (!state) return + state.screen.resize(width, height) +} + +// Scroll the pane's view into scrollback history. +// Returns the new scroll offset (0 = live view). +export function scrollPane(sessionName: string, direction: "up" | "down", lines = 5): number { + const state = panes.get(sessionName) + if (!state) return 0 + const screen = state.screen + const maxOffset = screen.scrollback.length + + if (direction === "up") { + screen.scrollOffset = Math.min(screen.scrollOffset + lines, maxOffset) + } else { + screen.scrollOffset = Math.max(screen.scrollOffset - lines, 0) + } + + // Push a frame update so the pane redraws with the new scroll position + const frame: CaptureResult = { + lines: screen.getLines(), + cursorX: screen.cursorX, + cursorY: screen.cursorY, + width: screen.width, + height: screen.height, + } + for (const cb of state.callbacks) { + try { cb(frame) } catch {} + } + + return screen.scrollOffset +} + +export function getScrollOffset(sessionName: string): number { + const state = panes.get(sessionName) + return state?.screen.scrollOffset ?? 0 +} + +export function stopAllCaptures(): void { + for (const [name] of panes) stopCapture(name) +} + +// Hash for diffing — skip re-render if nothing changed +const lastHashes = new Map() + +export function hasChanged(lines: string[], key = "_default"): boolean { + let h = 5381 + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + for (let j = 0; j < line.length; j++) { + h = ((h << 5) + h + line.charCodeAt(j)) | 0 + } + h = ((h << 5) + h + 10) | 0 + } + const prev = lastHashes.get(key) + if (prev === h) return false + lastHashes.set(key, h) + return true +} + +export function resetHash(key = "_default") { + lastHashes.delete(key) +} diff --git a/src/pty/session-manager.ts b/src/pty/session-manager.ts new file mode 100644 index 0000000..eeb106a --- /dev/null +++ b/src/pty/session-manager.ts @@ -0,0 +1,142 @@ +// Direct PTY session management — no tmux. +// Each session spawns a pty-helper subprocess that owns a real PTY. +// I/O flows through Bun.spawn stdin/stdout pipes. + +import type { Subprocess } from "bun" +import { resolve } from "path" + +export interface PtySession { + name: string + projectPath: string + projectName: string + sessionId?: string + targetBranch?: string + alive: boolean + width: number + height: number + colorIndex: number + proc: Subprocess<"pipe", "pipe", "pipe"> +} + +const sessions = new Map() +let colorCounter = 0 + +// Resolve pty-helper binary path relative to this source file +const PTY_HELPER = resolve(import.meta.dir, "..", "..", "bin", "pty-helper") + +export function getSessions(): Map { + return sessions +} + +export function getSessionByProject(projectPath: string): PtySession[] { + return [...sessions.values()].filter(s => s.projectPath === projectPath) +} + +export async function createSession(opts: { + projectPath: string + projectName: string + sessionId?: string + targetBranch?: string + width: number + height: number +}): Promise { + const slug = opts.projectName.replace(/[^a-zA-Z0-9-]/g, "-").slice(0, 20) + const ts = Date.now().toString(36) + const name = `cladm-${slug}-${ts}` + + const cmd = buildClaudeCmd(opts.projectPath, opts.sessionId, opts.targetBranch) + + const proc = Bun.spawn([ + PTY_HELPER, + String(opts.height), + String(opts.width), + "bash", "-c", cmd, + ], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, TERM: "xterm-256color", CLAUDECODE: "" }, + }) + + // Assign color index based on project path (same project = same color) + const existingForProject = getSessionByProject(opts.projectPath) + const ci = existingForProject.length > 0 + ? existingForProject[0].colorIndex + : colorCounter++ + + const session: PtySession = { + name, + projectPath: opts.projectPath, + projectName: opts.projectName, + sessionId: opts.sessionId, + targetBranch: opts.targetBranch, + alive: true, + width: opts.width, + height: opts.height, + colorIndex: ci, + proc, + } + + sessions.set(name, session) + + // Monitor for exit + proc.exited.then(() => { + session.alive = false + }) + + return session +} + +export function killSession(name: string): void { + const session = sessions.get(name) + if (!session) return + if (!session.proc.killed) { + session.proc.stdin.end() + session.proc.kill() + } + session.alive = false + sessions.delete(name) +} + +export function resizeSession(name: string, width: number, height: number): void { + const session = sessions.get(name) + if (!session || !session.alive) return + session.width = width + session.height = height + // Send APC resize command: \x1b_R;\x1b\\ + const resizeCmd = `\x1b_R${height};${width}\x1b\\` + session.proc.stdin.write(resizeCmd) +} + +export function writeToSession(name: string, data: string): void { + const session = sessions.get(name) + if (!session || !session.alive) return + session.proc.stdin.write(data) +} + +export function isAlive(name: string): boolean { + const session = sessions.get(name) + if (!session) return false + return session.alive && !session.proc.killed +} + +export function refreshAlive(): void { + for (const [name, session] of sessions) { + if (session.proc.killed || !session.alive) { + sessions.delete(name) + } + } +} + +export function cleanupAll(): void { + for (const [name] of sessions) killSession(name) +} + +function buildClaudeCmd(path: string, sessionId?: string, targetBranch?: string): string { + const base = `cd '${path}' && claude --dangerously-skip-permissions` + const branchFlag = targetBranch + ? ` -p "switch to branch ${targetBranch}, stash if needed"` + : "" + if (sessionId) return `${base} --resume '${sessionId}'${branchFlag}` + return `${base}${branchFlag}` +} diff --git a/src/tmux/capture.ts b/src/tmux/capture.ts index 9134031..8f74fe0 100644 --- a/src/tmux/capture.ts +++ b/src/tmux/capture.ts @@ -1,3 +1,9 @@ +// Captures tmux pane content via persistent streaming subprocesses. +// Push-based: notifies subscribers immediately when new frames arrive. +// Zero polling overhead on the JS side — callbacks fire on stream data. + +import type { Subprocess } from "bun" + export interface CaptureResult { lines: string[] cursorX: number @@ -6,29 +12,157 @@ export interface CaptureResult { height: number } -export async function capturePane(sessionName: string): Promise { - try { - const [contentProc, infoProc] = [ - Bun.spawn(["tmux", "capture-pane", "-t", sessionName, "-p", "-e"], { stdout: "pipe", stderr: "ignore" }), - Bun.spawn(["tmux", "display-message", "-t", sessionName, "-p", "#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}"], { stdout: "pipe", stderr: "ignore" }), - ] +const SEP = "%%CLADM_FRAME%%" - const [contentText, infoText] = await Promise.all([ - new Response(contentProc.stdout).text(), - new Response(infoProc.stdout).text(), - ]) +type FrameCallback = (frame: CaptureResult) => void - const [codeContent, codeInfo] = await Promise.all([contentProc.exited, infoProc.exited]) - if (codeContent !== 0 || codeInfo !== 0) return null +interface PaneCapture { + proc: Subprocess<"ignore", "pipe", "ignore"> + latest: CaptureResult | null + buf: string + callbacks: Set +} - const parts = infoText.trim().split(" ") +const panes = new Map() + +export function startCapture(sessionName: string, intervalMs = 100): void { + if (panes.has(sessionName)) return + + const script = `while true; do +tmux capture-pane -t '${sessionName}' -p -e 2>/dev/null +echo '${SEP}' +tmux display-message -t '${sessionName}' -p '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}' 2>/dev/null +echo '${SEP}END' +sleep ${(intervalMs / 1000).toFixed(3)} +done` + + const proc = Bun.spawn(["sh", "-c", script], { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + }) + + const state: PaneCapture = { proc, latest: null, buf: "", callbacks: new Set() } + panes.set(sessionName, state) + + const reader = proc.stdout.getReader() + const decoder = new TextDecoder() + + ;(async () => { + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + state.buf += decoder.decode(value, { stream: true }) + processBuffer(sessionName, state) + } + } catch { + // Process died + } + })() +} + +function processBuffer(sessionName: string, state: PaneCapture) { + while (true) { + const endMarker = SEP + "END" + const endIdx = state.buf.indexOf(endMarker) + if (endIdx < 0) break + + const frame = state.buf.slice(0, endIdx) + state.buf = state.buf.slice(endIdx + endMarker.length) + if (state.buf.startsWith("\n")) state.buf = state.buf.slice(1) + + const sepIdx = frame.indexOf(SEP) + if (sepIdx < 0) continue + + const contentText = frame.slice(0, sepIdx) + const infoText = frame.slice(sepIdx + SEP.length).trim() + + const parts = infoText.split(" ") + const cursorX = parseInt(parts[0]) || 0 + const cursorY = parseInt(parts[1]) || 0 + const width = parseInt(parts[2]) || 80 + const height = parseInt(parts[3]) || 24 + + const lines = contentText.split("\n") + if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop() + + state.latest = { lines, cursorX, cursorY, width, height } + + // Push to all subscribers immediately + for (const cb of state.callbacks) { + try { cb(state.latest) } catch {} + } + } +} + +// Subscribe to frame updates. Returns unsubscribe function. +export function onFrame(sessionName: string, cb: FrameCallback): () => void { + const state = panes.get(sessionName) + if (state) state.callbacks.add(cb) + // Unsub looks up current state (handles setCaptureRate restarts) + return () => { + const s = panes.get(sessionName) + if (s) s.callbacks.delete(cb) + } +} + +export function getLatestFrame(sessionName: string): CaptureResult | null { + return panes.get(sessionName)?.latest ?? null +} + +export function stopCapture(sessionName: string): void { + const state = panes.get(sessionName) + if (!state) return + if (!state.proc.killed) state.proc.kill() + state.callbacks.clear() + panes.delete(sessionName) +} + +// Change capture rate without losing subscribers +export function setCaptureRate(sessionName: string, intervalMs: number): void { + const state = panes.get(sessionName) + if (!state) return + const savedCallbacks = new Set(state.callbacks) + stopCapture(sessionName) + startCapture(sessionName, intervalMs) + const newState = panes.get(sessionName) + if (newState) { + for (const cb of savedCallbacks) newState.callbacks.add(cb) + } +} + +export function stopAllCaptures(): void { + for (const [name] of panes) stopCapture(name) +} + +// Legacy one-shot capture (for non-grid use) +export async function capturePane(sessionName: string): Promise { + const latest = getLatestFrame(sessionName) + if (latest) return latest + + try { + const proc = Bun.spawn(["sh", "-c", + `tmux capture-pane -t '${sessionName}' -p -e && echo '${SEP}' && tmux display-message -t '${sessionName}' -p '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'` + ], { stdout: "pipe", stderr: "ignore" }) + + const text = await new Response(proc.stdout).text() + const code = await proc.exited + if (code !== 0) return null + + const sepIdx = text.lastIndexOf(SEP) + if (sepIdx < 0) return null + + const contentText = text.slice(0, sepIdx) + const infoText = text.slice(sepIdx + SEP.length).trim() + + const parts = infoText.split(" ") const cursorX = parseInt(parts[0]) || 0 const cursorY = parseInt(parts[1]) || 0 const width = parseInt(parts[2]) || 80 const height = parseInt(parts[3]) || 24 const lines = contentText.split("\n") - // Remove trailing empty line from split if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop() return { lines, cursorX, cursorY, width, height } @@ -37,17 +171,24 @@ export async function capturePane(sessionName: string): Promise() -export function hasChanged(lines: string[]): boolean { - // Simple fast hash: join first+last few lines + length - const h = lines.length + ":" + (lines[0] || "") + (lines[lines.length - 1] || "") - if (h === lastHash) return false - lastHash = h +export function hasChanged(lines: string[], key = "_default"): boolean { + let h = 5381 + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + for (let j = 0; j < line.length; j++) { + h = ((h << 5) + h + line.charCodeAt(j)) | 0 + } + h = ((h << 5) + h + 10) | 0 + } + const prev = lastHashes.get(key) + if (prev === h) return false + lastHashes.set(key, h) return true } -export function resetHash() { - lastHash = "" +export function resetHash(key = "_default") { + lastHashes.delete(key) } diff --git a/src/tmux/input-bridge.ts b/src/tmux/input-bridge.ts index 01d912f..9b23eb9 100644 --- a/src/tmux/input-bridge.ts +++ b/src/tmux/input-bridge.ts @@ -1,31 +1,77 @@ -// Forwards raw terminal input sequences to a tmux session +// Forwards raw terminal input to tmux sessions via a persistent shell. +// Zero process-spawn overhead per keystroke — writes to stdin of a long-lived sh. -export async function sendKeys(sessionName: string, rawSequence: string): Promise { - // Use -l for literal text to avoid tmux key-name interpretation - // But special keys need to be sent without -l +import type { Subprocess } from "bun" + +let shell: Subprocess<"ignore", "pipe", "ignore"> | null = null + +function getShell() { + if (shell && !shell.killed) return shell + shell = Bun.spawn(["sh"], { + stdin: "pipe", + stdout: "ignore", + stderr: "ignore", + }) + return shell +} + +export function sendKeys(sessionName: string, rawSequence: string): void { + const sh = getShell() const special = mapSpecialSequence(rawSequence) + let cmd: string if (special) { - const proc = Bun.spawn(["tmux", "send-keys", "-t", sessionName, special], { - stdout: "ignore", stderr: "ignore", - }) - await proc.exited + cmd = `tmux send-keys -t '${sessionName}' ${special}\n` } else { - // Literal text - send as hex to avoid escaping issues - const hexBytes = [...rawSequence].map(c => { - const code = c.charCodeAt(0) - return code.toString(16).padStart(2, "0") - }) - const proc = Bun.spawn(["tmux", "send-keys", "-t", sessionName, "-H", ...hexBytes], { - stdout: "ignore", stderr: "ignore", - }) - await proc.exited + const hex = [...rawSequence].map(c => c.charCodeAt(0).toString(16).padStart(2, "0")).join(" ") + cmd = `tmux send-keys -t '${sessionName}' -H ${hex}\n` } + + sh.stdin.write(cmd) +} + +// Forward mouse events to tmux as SGR escape sequences +export function sendMouseEvent(sessionName: string, x: number, y: number, btn: number, release: boolean): void { + const sh = getShell() + const end = release ? "m" : "M" + const seq = `\x1b[<${btn};${x};${y}${end}` + const hex = [...seq].map(c => c.charCodeAt(0).toString(16).padStart(2, "0")).join(" ") + const cmd = `tmux send-keys -t '${sessionName}' -H ${hex}\n` + sh.stdin.write(cmd) +} + +// Scroll a tmux pane using copy-mode (works with any application in the pane) +export function sendScroll(sessionName: string, direction: "up" | "down", lines = 3): void { + const sh = getShell() + if (direction === "up") { + // Enter copy mode (no-op if already in it) then scroll up + sh.stdin.write(`tmux copy-mode -t '${sessionName}' 2>/dev/null; tmux send-keys -t '${sessionName}' -X -N ${lines} scroll-up 2>/dev/null\n`) + } else { + // Scroll down in copy mode; if we hit bottom, exit copy mode + sh.stdin.write(`tmux send-keys -t '${sessionName}' -X -N ${lines} scroll-down 2>/dev/null\n`) + } +} + +// Exit copy mode (e.g., when user starts typing) +export function exitCopyMode(sessionName: string): void { + const sh = getShell() + sh.stdin.write(`tmux send-keys -t '${sessionName}' -X cancel 2>/dev/null\n`) +} + +export function cleanupInputQueue(_sessionName: string) { + // No per-session cleanup needed with shared shell +} + +export function destroyShell() { + if (shell && !shell.killed) { + shell.stdin.end() + shell.kill() + } + shell = null } // Map known ANSI escape sequences to tmux key names function mapSpecialSequence(seq: string): string | null { - // Common escape sequences -> tmux key names const MAP: Record = { "\r": "Enter", "\n": "Enter", diff --git a/src/tmux/session-manager.ts b/src/tmux/session-manager.ts index ad39888..3d33964 100644 --- a/src/tmux/session-manager.ts +++ b/src/tmux/session-manager.ts @@ -63,6 +63,12 @@ export async function createSession(opts: { } sessions.set(name, session) + + // Enable mouse mode so clicks/scrolls forward to the application + Bun.spawn(["tmux", "set", "-t", name, "mouse", "on"], { + stdout: "ignore", stderr: "ignore", + }) + return session } From 6488816d89ae2e0d287033f7e7f9f676a8c710a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 28 Feb 2026 02:01:17 +0000 Subject: [PATCH 02/13] refactor: extract index.ts into 7 focused modules Break 1507-line index.ts into a slim 251-line entry point plus: - lib/theme.ts, lib/state.ts (shared constants + app singleton) - ui/formatters.ts, ui/panels.ts (row formatting + UI updates) - input/parser.ts, input/handlers.ts (stdin parsing + keyboard/mouse) - grid/view-switch.ts, actions/launch.ts (grid switching + PTY launch) Co-Authored-By: Claude Opus 4.6 --- src/actions/launch.ts | 71 ++ src/grid/view-switch.ts | 29 + src/index.ts | 1404 +++------------------------------------ src/input/handlers.ts | 492 ++++++++++++++ src/input/parser.ts | 102 +++ src/lib/state.ts | 50 ++ src/lib/theme.ts | 4 + src/ui/formatters.ts | 144 ++++ src/ui/panels.ts | 321 +++++++++ 9 files changed, 1287 insertions(+), 1330 deletions(-) create mode 100644 src/actions/launch.ts create mode 100644 src/grid/view-switch.ts create mode 100644 src/input/handlers.ts create mode 100644 src/input/parser.ts create mode 100644 src/lib/state.ts create mode 100644 src/lib/theme.ts create mode 100644 src/ui/formatters.ts create mode 100644 src/ui/panels.ts diff --git a/src/actions/launch.ts b/src/actions/launch.ts new file mode 100644 index 0000000..30e0678 --- /dev/null +++ b/src/actions/launch.ts @@ -0,0 +1,71 @@ +import { app } from "../lib/state" +import { updateAll, rebuildDisplayRows } from "../ui/panels" +import { ensureGridView } from "../grid/view-switch" +import { loadSessions } from "../data/sessions" +import { createSession } from "../pty/session-manager" + +export async function doLaunch() { + if (app.selectedProjects.size === 0 && app.selectedSessions.size === 0) return + if (app.demoMode) { + app.selectedProjects.clear() + app.selectedSessions.clear() + app.selectedBranches.clear() + rebuildDisplayRows() + updateAll() + return + } + + const items: { path: string; name: string; sessionId?: string; targetBranch?: string }[] = [] + + for (const path of app.selectedProjects) { + const project = app.projects.find(p => p.path === path) + if (!project) continue + const targetBranch = app.selectedBranches.get(path) + const needsBranch = targetBranch && targetBranch !== project.branch + if (!project.sessions) { + project.sessions = await loadSessions(project.path) + project.sessionCount = project.sessions.length + } + const lastSessionId = project.sessions[0]?.id + items.push({ path, name: project.name, sessionId: lastSessionId, targetBranch: needsBranch ? targetBranch : undefined }) + } + + for (const project of app.projects) { + if (!project.sessions) continue + for (const session of project.sessions) { + if (app.selectedSessions.has(session.id)) { + const targetBranch = app.selectedBranches.get(project.path) + const needsBranch = targetBranch && targetBranch !== project.branch + items.push({ path: project.path, name: project.name, sessionId: session.id, targetBranch: needsBranch ? targetBranch : undefined }) + } + } + } + + if (items.length === 0) return + + ensureGridView() + + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const totalPanes = items.length + (app.directGrid?.paneCount || 0) + const cols = totalPanes <= 1 ? 1 : totalPanes <= 2 ? 2 : totalPanes <= 4 ? 2 : 3 + const rows = Math.ceil(totalPanes / cols) + const paneW = Math.max(Math.floor(termW / cols) - 2, 20) + const paneH = Math.max(Math.floor((termH - 2) / rows) - 4, 6) + + for (const item of items) { + const session = await createSession({ + projectPath: item.path, + projectName: item.name, + sessionId: item.sessionId, + targetBranch: item.targetBranch, + width: paneW, + height: paneH, + }) + await app.directGrid!.addPane(session) + } + + app.selectedProjects.clear() + app.selectedSessions.clear() + app.selectedBranches.clear() +} diff --git a/src/grid/view-switch.ts b/src/grid/view-switch.ts new file mode 100644 index 0000000..88024f1 --- /dev/null +++ b/src/grid/view-switch.ts @@ -0,0 +1,29 @@ +import { app } from "../lib/state" +import { DirectGridRenderer } from "../components/direct-grid" + +export function ensureGridView() { + if (app.viewMode === "grid" && app.directGrid) return + switchToGrid() +} + +export function switchToGrid() { + app.viewMode = "grid" + + if (!app.directGrid) { + app.directGrid = new DirectGridRenderer(app.rawStdoutWrite) + } + + app.renderer.suspend() + + if (process.stdin.isTTY) process.stdin.setRawMode(true) + process.stdin.resume() + app.rawStdoutWrite("\x1b[?1049h") + app.rawStdoutWrite("\x1b[?1000h") + app.rawStdoutWrite("\x1b[?1006h") + app.directGrid.start() +} + +export function resizeGridPanes() { + if (!app.directGrid || app.directGrid.paneCount === 0) return + app.directGrid.repositionAll() +} diff --git a/src/index.ts b/src/index.ts index 468fd65..c6b2ebf 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,1091 +1,99 @@ #!/usr/bin/env bun import { createCliRenderer, - Box, - Text, BoxRenderable, TextRenderable, ScrollBoxRenderable, - t, - bold, - dim, - fg, - green, - yellow, - cyan, - magenta, - type KeyEvent, - type CliRenderer, } from "@opentui/core" import { discoverProjects } from "./data/history" -import { loadGitMetadata, loadBranches } from "./data/git" +import { loadGitMetadata } from "./data/git" import { loadSessions } from "./data/sessions" -import { generateMockProjects, generateMockSessions, generateMockBranches, generateMockBusySessions } from "./data/mock" -import { detectActiveSessions, updateProjectSessions, generateMockActiveSessions, focusTerminalByPath, checkTransitions, snapshotBusy, playDoneSound, bounceDock, getSessionStatus, populateMockSessionStatus, getIdleSessions } from "./data/monitor" -import { getUsageSummary, formatCost, formatWindow, makeBar, pct, PLAN_LIMITS, type UsageSummary } from "./data/usage" -import { launchSelections } from "./actions/launcher" -import { createSession, getSessions, refreshAlive, type PtySession } from "./pty/session-manager" +import { generateMockProjects, generateMockSessions, generateMockBusySessions } from "./data/mock" +import { detectActiveSessions, updateProjectSessions, generateMockActiveSessions, checkTransitions, snapshotBusy, playDoneSound, bounceDock, getSessionStatus, populateMockSessionStatus } from "./data/monitor" +import { getUsageSummary } from "./data/usage" +import { getSessions, refreshAlive } from "./pty/session-manager" import { stopAllCaptures } from "./pty/capture" -import { DirectGridRenderer } from "./components/direct-grid" -import type { Project, DisplayRow } from "./lib/types" -import { timeAgo, formatSize, elapsedCompact } from "./lib/time" +import { DIM_CLR } from "./lib/theme" +import { app } from "./lib/state" +import { updateAll, rebuildDisplayRows, updateUsagePanel, updateColumnHeaders } from "./ui/panels" +import { stdinHandler } from "./input/handlers" +import { resizeGridPanes } from "./grid/view-switch" -// ─── Theme ────────────────────────────────────────────────────────── -const CURSOR_BG = "#283457" -const ACTIVE_BG = "#1a2e1a" -const ACCENT = "#7aa2f7" -const DIM_CLR = "#565f89" - -// ─── State ────────────────────────────────────────────────────────── -const demoMode = Bun.argv.includes("--demo") -let projects: Project[] = [] -const selectedProjects = new Set() -const selectedSessions = new Set() -const selectedBranches = new Map() -let cursor = 0 -let sortMode = 0 -const sortLabels = ["recent", "name", "commit", "sessions"] -let sortedIndices: number[] = [] -let displayRows: DisplayRow[] = [] -let monitorInterval: ReturnType | null = null -let prevBusySnapshot: Map = new Map() -let bottomPanelMode: "preview" | "idle" = "preview" -let destroyed = false -let idleCursor = 0 -let cachedIdleSessions: import("./data/monitor").IdleSessionInfo[] = [] - -// ─── Grid Mode State ─────────────────────────────────────────────── -type ViewMode = "picker" | "grid" -let viewMode: ViewMode = "picker" -let directGrid: DirectGridRenderer | null = null -let mainBox: BoxRenderable | null = null -let rawStdoutWrite: (s: string) => boolean - -// ─── UI Refs ──────────────────────────────────────────────────────── -let renderer: CliRenderer -let headerText: TextRenderable -let colHeaderText: TextRenderable -let listBox: ScrollBoxRenderable -let bottomRow: BoxRenderable -let previewBox: BoxRenderable -let previewText: TextRenderable -let usageBox: BoxRenderable -let footerText: TextRenderable -let cachedUsage: UsageSummary | null = null - -// ─── Display Rows ─────────────────────────────────────────────────── -function rebuildDisplayRows() { - displayRows = [] - for (const idx of sortedIndices) { - const project = projects[idx] - displayRows.push({ type: "project", projectIndex: idx }) - if (project.expanded) { - if (project.branches) { - for (const br of project.branches) { - if (!br.isCurrent) { - displayRows.push({ type: "branch", projectIndex: idx, branchName: br.name }) - } - } - } - if (project.sessions) { - for (let si = 0; si < project.sessions.length; si++) { - displayRows.push({ type: "session", projectIndex: idx, sessionIndex: si }) - } - } - displayRows.push({ type: "new-session", projectIndex: idx }) - } - } -} - -// ─── Sort ─────────────────────────────────────────────────────────── -function applySortMode() { - const indices = Array.from(projects.keys()) - switch (sortMode) { - case 0: - sortedIndices = indices - break - case 1: - sortedIndices = indices.sort((a, b) => - projects[a].name.localeCompare(projects[b].name) - ) - break - case 2: - sortedIndices = indices.sort( - (a, b) => (projects[b].commitEpoch || 0) - (projects[a].commitEpoch || 0) - ) - break - case 3: - sortedIndices = indices.sort( - (a, b) => projects[b].sessionCount - projects[a].sessionCount - ) - break - } - rebuildDisplayRows() -} - -// ─── Row Formatting ───────────────────────────────────────────────── -function fmtSyncIndicator(ahead: number, behind: number): string { - if (ahead === -1 && behind === -1) return "✗" - if (ahead === 0 && behind === 0) return "✓" - const parts: string[] = [] - if (ahead > 0) parts.push(`↑${ahead}`) - if (behind > 0) parts.push(`↓${behind}`) - return parts.join("") -} - -function fmtProjectRow(project: Project, isSelected: boolean) { - let activeDot: string - let activeTag: string - if (project.activeSessions > 0) { - if (project.busySessions > 0) { - activeDot = green("●") - const count = String(project.activeSessions) - activeTag = project.activeSessions > 1 ? yellow((count + " ").slice(0, 2)) : " " - } else { - activeDot = yellow("◉") - const elapsed = elapsedCompact(project.lastActivityMs) - activeTag = elapsed ? dim((elapsed + " ").slice(0, 2)) : " " - } - } else { - activeDot = dim("○") - activeTag = " " - } - const check = isSelected ? green("✓") : " " - const arrow = project.expanded ? "▼" : "▶" - const name = - project.name.length > 28 ? project.name.slice(0, 25) + "..." : project.name - const branch = - project.branch.length > 8 - ? project.branch.slice(0, 7) + "…" - : project.branch - - const sync = fmtSyncIndicator(project.ahead, project.behind) - const syncCol = sync === "✓" ? green(sync.padEnd(5)) - : sync === "✗" ? dim(sync.padEnd(5)) - : yellow(sync.padEnd(5)) - - const dirtyCol = project.dirty - ? yellow(project.dirty.padEnd(9)) - : green("clean".padEnd(9)) - - const ca = project.claudeAgo - let claudeCol - if (ca === "never" || ca === "-") claudeCol = dim(ca.padEnd(9)) - else if (ca.includes("m ago") || ca.includes("h ago") || ca === "just now") - claudeCol = cyan(ca.padEnd(9)) - else if (ca.includes("d ago")) claudeCol = green(ca.padEnd(9)) - else claudeCol = dim(ca.padEnd(9)) - - return t` ${activeDot}${activeTag}[${check}] ${dim(arrow)} ${name.padEnd(28)} ${magenta(branch.padEnd(9))}${syncCol}${dim( - (project.commitAge || "-").padEnd(10) - )}${(project.commitMsg || "-").padEnd(22)}${dirtyCol}${claudeCol}${dim( - String(project.sessionCount).padStart(3) - )} ${dim(String(project.totalMessages).padStart(5))} ${dim(project.tags)}` -} - -function fmtSessionRow( - projectIdx: number, - sessionIdx: number, - isSelected: boolean, - isLastSession: boolean -) { - const project = projects[projectIdx] - const session = project.sessions![sessionIdx] - const check = isSelected ? green("✓") : " " - const prefix = isLastSession ? "│ " : "├─" - const title = - session.title.length > 55 - ? session.title.slice(0, 52) + "..." - : session.title - const age = timeAgo(session.timestamp) - const size = formatSize(session.sizeBytes) - - const status = getSessionStatus(project.path, session.id) - - const promptText = session.lastUserPrompt - ? session.lastUserPrompt.length > 60 - ? session.lastUserPrompt.slice(0, 57) + "..." - : session.lastUserPrompt - : "(no text)" - const responseText = session.lastAssistantMsg - ? session.lastAssistantMsg.length > 60 - ? session.lastAssistantMsg.slice(0, 57) + "..." - : session.lastAssistantMsg - : "(no text response)" - - if (status === "busy") { - return t` ${green("●")} ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim( - size.padEnd(7) - )} ${fg(ACCENT)('"' + title + '"')} ${green("running")} - ${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')} - ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}` - } - if (status === "idle") { - return t` ${yellow("◉")} ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim( - size.padEnd(7) - )} ${fg(ACCENT)('"' + title + '"')} ${yellow("idle")} - ${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')} - ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}` - } - return t` ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim( - size.padEnd(7) - )} ${fg(ACCENT)('"' + title + '"')} - ${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')} - ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}` -} - -function fmtNewSessionRow(projectIdx: number, isSelected: boolean) { - const check = isSelected ? green("✓") : " " - return t` ${dim("└─")} [${check}] ${green("+ New session")}` -} - -function fmtBranchRow(projectIdx: number, branchName: string, isSelected: boolean) { - const project = projects[projectIdx] - const br = project.branches?.find(b => b.name === branchName) - if (!br) return t` ${dim("├─")} ${branchName}` - - const check = isSelected ? green("✓") : " " - const sync = fmtSyncIndicator(br.ahead, br.behind) - const syncCol = sync === "✓" ? green(sync) - : sync === "✗" ? dim(sync) - : yellow(sync) - const msg = br.lastCommitMsg.length > 40 ? br.lastCommitMsg.slice(0, 37) + "..." : br.lastCommitMsg - - return t` ${dim("├─")} [${check}] ${magenta(branchName.padEnd(18))} ${syncCol} ${dim(br.lastCommitAge.padEnd(9))} ${dim(msg)}` -} - -// ─── UI Updates ───────────────────────────────────────────────────── -function updateHeader() { - const total = selectedProjects.size + selectedSessions.size - const branchNote = selectedBranches.size > 0 ? ` (${selectedBranches.size} branch switch)` : "" - const modeLabel = demoMode ? " [DEMO]" : "" - const activeCount = projects.reduce((sum, p) => sum + (p.activeSessions > 0 ? 1 : 0), 0) - const busyCount = projects.reduce((sum, p) => sum + (p.busySessions > 0 ? 1 : 0), 0) - const idleCount = activeCount - busyCount - if (activeCount > 0) { - headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${String(total)} selected${branchNote} ${dim( - `sort: ${sortLabels[sortMode]} │ ${projects.length} projects` - )} │ ${green(`${busyCount} busy`)} ${yellow(`${idleCount} idle`)}` - } else { - headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${String(total)} selected${branchNote} ${dim( - `sort: ${sortLabels[sortMode]} │ ${projects.length} projects` - )}` - } -} - -function updateColumnHeaders() { - const cols = ` ${"PROJECT".padEnd(30)} ${"BRANCH".padEnd(9)}${"SYNC".padEnd(5)}${"COMMIT".padEnd(10)}${"MESSAGE".padEnd(22)}${"DIRTY".padEnd(9)}${"LAST USE".padEnd(9)}${"SES".padStart(3)} ${"MSGS".padStart(5)} STACK` - colHeaderText.content = t` ${dim(cols)}` -} - -function addIdleRow(s: { idleSinceMs: number; projectName: string; sessionTitle: string; lastPrompt: string; lastResponse: string }, isCursor: boolean) { - const elapsed = (elapsedCompact(s.idleSinceMs) || "<5s").padEnd(6) - const name = s.projectName.length > 20 ? s.projectName.slice(0, 17) + "..." : s.projectName - const title = s.sessionTitle.length > 50 ? s.sessionTitle.slice(0, 47) + "..." : s.sessionTitle - const prompt = s.lastPrompt - ? s.lastPrompt.length > 60 ? s.lastPrompt.slice(0, 57) + "..." : s.lastPrompt - : "(no text)" - const response = s.lastResponse - ? s.lastResponse.length > 60 ? s.lastResponse.slice(0, 57) + "..." : s.lastResponse - : "(no response)" - const pointer = isCursor ? "▸" : " " - previewBox.add(Text({ content: t` ${yellow("◉")} ${isCursor ? cyan(pointer) : dim(pointer)} ${dim(elapsed)}${bold(name)} ${fg(ACCENT)('"' + title + '"')}`, width: "100%", height: 1 })) - previewBox.add(Text({ content: t` ${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + prompt + '"')}`, width: "100%", height: 1 })) - previewBox.add(Text({ content: t` ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + response + '"')}`, width: "100%", height: 1 })) -} - -function updateIdlePanel() { - cachedIdleSessions = getIdleSessions(projects) - const n = cachedIdleSessions.length - previewBox.title = ` Idle Sessions (${n}) — enter to focus ` - // Clear all children and rebuild - for (const child of previewBox.getChildren()) previewBox.remove(child.id) - if (n === 0) { - idleCursor = 0 - previewBox.add(Text({ content: t`${dim(" No idle sessions")}`, width: "100%", height: 1 })) - return - } - if (idleCursor >= n) idleCursor = n - 1 - const show = cachedIdleSessions.slice(0, 3) - for (let i = 0; i < show.length; i++) { - addIdleRow(show[i], idleCursor === i) - } - if (n > 3) { - previewBox.add(Text({ content: t` ${dim(`+${n - 3} more`)}`, width: "100%", height: 1 })) - } -} - -function updateBottomPanel() { - if (bottomPanelMode === "idle") { - bottomRow.height = 14 - updateIdlePanel() - } else { - // Restore previewText as sole child - for (const child of previewBox.getChildren()) previewBox.remove(child.id) - previewBox.add(previewText) - bottomRow.height = 10 - previewBox.title = " Preview " - updatePreview() - } -} - -function usageBarColor(p: number) { - return p >= 80 ? yellow : p >= 50 ? cyan : green -} - -function updateUsagePanel() { - if (destroyed) return - for (const child of usageBox.getChildren()) usageBox.remove(child.id) - - if (!cachedUsage) { - usageBox.title = " Usage " - usageBox.add(Text({ content: t`${dim("Loading...")}`, width: "100%", height: 1 })) - return - } - - const u = cachedUsage - const BAR_W = 18 - - // ── Current session ── - const sPct = pct(u.totalCost, PLAN_LIMITS.session) - const sBar = makeBar(u.totalCost, PLAN_LIMITS.session, BAR_W) - const sReset = u.sessionResetMs > 0 ? formatWindow(u.sessionResetMs) : "" - usageBox.title = " Usage " - usageBox.add(Text({ content: t`${bold("Session")}`, width: "100%", height: 1 })) - usageBox.add(Text({ content: t`${usageBarColor(sPct)(sBar)} ${bold(String(sPct) + "%")}`, width: "100%", height: 1 })) - usageBox.add(Text({ content: t`${dim(sReset ? "resets " + sReset : "")} ${dim(formatCost(u.costPerHour) + "/h")}`, width: "100%", height: 1 })) - - // ── Weekly all models ── - const wPct = pct(u.weekTotal, PLAN_LIMITS.weeklyAll) - const wBar = makeBar(u.weekTotal, PLAN_LIMITS.weeklyAll, BAR_W) - usageBox.add(Text({ content: t`${bold("All models")} ${dim(formatCost(u.weekTotal))}`, width: "100%", height: 1 })) - usageBox.add(Text({ content: t`${usageBarColor(wPct)(wBar)} ${bold(String(wPct) + "%")}`, width: "100%", height: 1 })) - - // ── Weekly sonnet only ── - const snPct = pct(u.weeklySonnetCost, PLAN_LIMITS.weeklySonnet) - const snBar = makeBar(u.weeklySonnetCost, PLAN_LIMITS.weeklySonnet, BAR_W) - usageBox.add(Text({ content: t`${bold("Sonnet")} ${dim(formatCost(u.weeklySonnetCost))}`, width: "100%", height: 1 })) - usageBox.add(Text({ content: t`${usageBarColor(snPct)(snBar)} ${bold(String(snPct) + "%")}`, width: "100%", height: 1 })) - - // ── Monthly total ── - const monthLabel = new Date().toLocaleString("en", { month: "short" }) - usageBox.add(Text({ content: t`${bold(monthLabel + " total")} ${dim(formatCost(u.monthlyTotalCost))}`, width: "100%", height: 1 })) - usageBox.add(Text({ content: t`${dim(formatCost(u.costPerHour) + "/h avg · " + u.totalRequests + " reqs")}`, width: "100%", height: 1 })) - - renderer.requestRender() -} - -function updateFooter() { - if (bottomPanelMode === "idle" && cachedIdleSessions.length > 0) { - footerText.content = t` ${dim( - "↑↓ nav │ tab/shift-tab idle select │ enter focus │ i preview │ space select │ a all │ n none │ s sort │ q quit" - )}` - } else { - footerText.content = t` ${dim( - "↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter grid │ o external │ q quit" - )}` - } -} - -function updatePreview() { - if (cursor >= displayRows.length) { - previewText.content = t`${dim(" No selection")}` - return - } - - const row = displayRows[cursor] - const project = projects[row.projectIndex] - - if (row.type === "project") { - previewText.content = t` ${bold(project.name)} ${dim(project.path)} - ${dim("Branch:")} ${magenta(project.branch)} ${dim("Commit:")} ${ - project.commitAge || "-" - } — ${project.commitMsg || "-"} - ${dim("Status:")} ${project.dirty ? yellow(project.dirty) : green("clean")} ${dim( - "Sessions:" - )} ${String(project.sessionCount)} ${dim("Msgs:")} ${String(project.totalMessages)} ${dim( - "Stack:" - )} ${project.tags || "-"}` - } else if (row.type === "session" && project.sessions) { - const s = project.sessions[row.sessionIndex!] - const sStatus = getSessionStatus(project.path, s.id) - const sLabel = sStatus === "busy" ? green(" ● running") : sStatus === "idle" ? yellow(" ◉ idle") : "" - previewText.content = t` ${bold("Session:")} ${s.title}${sLabel} - ${dim(timeAgo(s.timestamp))} · ${dim(formatSize(s.sizeBytes))} · ${magenta(s.branch || "-")} - ${dim("Last prompt:")} ${s.lastUserPrompt || dim("(no text)")} - ${dim("Claude:")} ${s.lastAssistantMsg || dim("(no text response)")}` - } else if (row.type === "branch" && project.branches) { - const br = project.branches.find(b => b.name === row.branchName) - if (br) { - const sync = fmtSyncIndicator(br.ahead, br.behind) - const selBranch = selectedBranches.get(project.path) - const selNote = selBranch === br.name - ? t` ${green("Selected")} — will launch with: ${dim(`-p "switch to branch ${br.name}, stash if needed"`)}` - : t` ${dim("Press space to select this branch for launch")}` - previewText.content = t` ${bold("Branch:")} ${magenta(br.name)} ${dim("Sync:")} ${sync} - ${dim("Last commit:")} ${br.lastCommitAge} — ${br.lastCommitMsg} -${selNote}` - } - } else { - previewText.content = t` ${green("Start a new Claude session")} in ${bold(project.name)} - ${dim(project.path)}` - } -} - -function rebuildList() { - for (const child of listBox.getChildren()) { - listBox.remove(child.id) - } - - for (let i = 0; i < displayRows.length; i++) { - const row = displayRows[i] - const isCursor = i === cursor - const project = projects[row.projectIndex] - - let content: ReturnType - let rowHeight = 1 - if (row.type === "project") { - const isSel = selectedProjects.has(project.path) - content = fmtProjectRow(project, isSel) - } else if (row.type === "session") { - const session = project.sessions![row.sessionIndex!] - const isSel = selectedSessions.has(session.id) - content = fmtSessionRow(row.projectIndex, row.sessionIndex!, isSel, false) - rowHeight = 3 - } else if (row.type === "branch") { - const isSel = selectedBranches.get(project.path) === row.branchName - content = fmtBranchRow(row.projectIndex, row.branchName!, isSel) - } else { - const isSel = selectedProjects.has(project.path) - content = fmtNewSessionRow(row.projectIndex, isSel) - } - - const isActiveProject = row.type === "project" && project.activeSessions > 0 - const isActiveSession = row.type === "session" && getSessionStatus(project.path, project.sessions![row.sessionIndex!].id) !== null - const bgColor = isCursor ? CURSOR_BG : (isActiveProject || isActiveSession) ? ACTIVE_BG : undefined - - if (bgColor) { - listBox.add( - Box( - { - backgroundColor: bgColor, - shouldFill: true, - width: "100%", - height: rowHeight, - }, - Text({ content }) - ) - ) - } else { - listBox.add(Text({ content, width: "100%", height: rowHeight })) - } - } - - ensureCursorVisible() - renderer.requestRender() -} - -function ensureCursorVisible() { - const vpH = listBox.viewport.height - if (vpH <= 0) return - - let cursorY = 0 - let cursorH = 1 - for (let i = 0; i < displayRows.length; i++) { - const h = displayRows[i].type === "session" ? 3 : 1 - if (i === cursor) { - cursorH = h - break - } - cursorY += h - } - - const top = listBox.scrollTop - if (cursorY < top) { - listBox.scrollTo(cursorY) - } else if (cursorY + cursorH > top + vpH) { - listBox.scrollTo(cursorY + cursorH - vpH) - } -} - -function updateAll() { - if (destroyed) return - updateHeader() - rebuildList() - updateBottomPanel() - updateFooter() -} - -// ─── Mouse Parsing ────────────────────────────────────────────────── -interface MouseEvent { btn: number; col: number; row: number; release: boolean } - -function parseMouseEvent(seq: string): MouseEvent | null { - // SGR encoding: \x1b[= 6 && seq.startsWith("\x1b[M")) { - const raw = seq.charCodeAt(3) - 32 - const col = seq.charCodeAt(4) - 32 - const row = seq.charCodeAt(5) - 32 - // In legacy mode, btn 3 = release, lower 2 bits = button - const release = (raw & 3) === 3 - const btn = release ? 0 : (raw & 3) - // Scroll: bit 6 set means scroll. 64 = scroll up, 65 = scroll down - const scroll = raw & 64 - return { btn: scroll ? (raw & 67) : btn, col, row, release: release && !scroll } - } - - return null -} - -// ─── Mouse ────────────────────────────────────────────────────────── -function hitTestListRow(screenRow: number): number { - // List starts at row 2 (header=0, colHeader=1) - const listStartY = 2 - const relY = screenRow - listStartY + listBox.scrollTop - if (relY < 0) return -1 - - let y = 0 - for (let i = 0; i < displayRows.length; i++) { - const h = displayRows[i].type === "session" ? 3 : 1 - if (relY >= y && relY < y + h) return i - y += h - } - return -1 -} - -function handlePickerClick(_col: number, screenRow: number) { - const idx = hitTestListRow(screenRow) - if (idx < 0 || idx >= displayRows.length) return - - cursor = idx - const row = displayRows[idx] - const project = projects[row.projectIndex] - - // Toggle selection (same as space key) - if (row.type === "project" || row.type === "new-session") { - const path = project.path - if (selectedProjects.has(path)) selectedProjects.delete(path) - else selectedProjects.add(path) - } else if (row.type === "session") { - const session = project.sessions![row.sessionIndex!] - if (selectedSessions.has(session.id)) selectedSessions.delete(session.id) - else selectedSessions.add(session.id) - } else if (row.type === "branch") { - const path = project.path - if (selectedBranches.get(path) === row.branchName) { - selectedBranches.delete(path) - } else { - selectedBranches.set(path, row.branchName!) - } - } - - updateAll() -} - -// ─── Keyboard ─────────────────────────────────────────────────────── -async function handleKeypress(key: KeyEvent) { - try { - const total = displayRows.length - if (total === 0) return - - switch (key.name) { - case "up": - if (cursor > 0) cursor-- - break - - case "down": - if (cursor < total - 1) cursor++ - break - - case "pageup": - cursor = Math.max(0, cursor - 15) - break - - case "pagedown": - cursor = Math.min(total - 1, cursor + 15) - break - - case "home": - cursor = 0 - break - - case "end": - cursor = total - 1 - break - - case "right": { - const row = displayRows[cursor] - if (row.type === "project") { - const project = projects[row.projectIndex] - if (!project.expanded) { - expandProject(row.projectIndex) - return - } - } - return - } - - case "left": { - const row = displayRows[cursor] - if (row.type === "project") { - projects[row.projectIndex].expanded = false - } else { - projects[row.projectIndex].expanded = false - const target = row.projectIndex - rebuildDisplayRows() - cursor = displayRows.findIndex( - (r) => r.type === "project" && r.projectIndex === target - ) - if (cursor < 0) cursor = 0 - } - rebuildDisplayRows() - if (cursor >= displayRows.length) cursor = displayRows.length - 1 - break - } - - case "space": { - const row = displayRows[cursor] - if (row.type === "project" || row.type === "new-session") { - const path = projects[row.projectIndex].path - if (selectedProjects.has(path)) selectedProjects.delete(path) - else selectedProjects.add(path) - } else if (row.type === "session") { - const session = projects[row.projectIndex].sessions![row.sessionIndex!] - if (selectedSessions.has(session.id)) selectedSessions.delete(session.id) - else selectedSessions.add(session.id) - } else if (row.type === "branch") { - const path = projects[row.projectIndex].path - if (selectedBranches.get(path) === row.branchName) { - selectedBranches.delete(path) - } else { - selectedBranches.set(path, row.branchName!) - } - } - break - } - - case "f": { - const row = displayRows[cursor] - const project = projects[row.projectIndex] - Bun.spawn(["open", project.path]) - break - } - - case "g": { - const row = displayRows[cursor] - const project = projects[row.projectIndex] - if (project.activeSessions > 0) { - const sid = row.type === "session" && project.sessions - ? project.sessions[row.sessionIndex!]?.id - : undefined - await focusTerminalByPath(project.path, sid) - } - return - } - - case "a": - for (const p of projects) selectedProjects.add(p.path) - break - - case "n": - selectedProjects.clear() - selectedSessions.clear() - selectedBranches.clear() - break - - case "i": - bottomPanelMode = bottomPanelMode === "preview" ? "idle" : "preview" - idleCursor = 0 - break - - case "tab": - if (bottomPanelMode === "idle" && cachedIdleSessions.length > 0) { - if (key.shift) { - idleCursor = idleCursor > 0 ? idleCursor - 1 : Math.min(cachedIdleSessions.length, 3) - 1 - } else { - idleCursor = (idleCursor + 1) % Math.min(cachedIdleSessions.length, 3) - } - } - break - - case "s": - sortMode = (sortMode + 1) % sortLabels.length - applySortMode() - cursor = 0 - break - - case "return": { - const hasSelections = selectedProjects.size > 0 || selectedSessions.size > 0 - // If user has checkmarked items, always launch them - if (hasSelections) { - doLaunch() - break - } - // Focus idle session from idle panel (only when nothing selected) - if (bottomPanelMode === "idle" && cachedIdleSessions.length > 0 && idleCursor < cachedIdleSessions.length) { - const focused = await focusTerminalByPath(cachedIdleSessions[idleCursor].projectPath) - if (focused) return - } - // If cursor is on a project row with active session, focus it - const returnRow = displayRows[cursor] - if ( - returnRow.type === "project" && - projects[returnRow.projectIndex].activeSessions > 0 - ) { - const focused = await focusTerminalByPath(projects[returnRow.projectIndex].path) - if (focused) return - } - doLaunch() - break - } - - case "o": { - // Open selected projects in external Terminal.app windows - const hasOSel = selectedProjects.size > 0 || selectedSessions.size > 0 - if (!hasOSel) { - // If nothing selected, select current row's project - const oRow = displayRows[cursor] - if (oRow) selectedProjects.add(projects[oRow.projectIndex].path) - } - if (selectedProjects.size > 0 || selectedSessions.size > 0) { - await launchSelections(projects, selectedProjects, selectedSessions, selectedBranches) - selectedProjects.clear() - selectedSessions.clear() - selectedBranches.clear() - } - break - } - - case "q": - case "escape": - destroyed = true - if (monitorInterval) clearInterval(monitorInterval) - stopAllCaptures() - process.stdout.write("\x1b[?1006l") - process.stdout.write("\x1b[?1000l") - renderer.destroy() - return - - case "t": - // Switch to grid view if there are panes - if (directGrid && directGrid.paneCount > 0) { - switchToGrid() - return - } - return - - default: - return - } - - updateAll() - } catch {} -} - -async function expandProject(projectIndex: number) { - const project = projects[projectIndex] - if (demoMode) { - if (!project.sessions) { - project.sessions = generateMockSessions(project.path) - project.sessionCount = project.sessions.length - } - if (!project.branches) { - project.branches = generateMockBranches(project.path) - } - populateMockSessionStatus(project) - } else { - const loads: Promise[] = [] - if (!project.sessions) { - loads.push( - loadSessions(project.path).then(s => { - project.sessions = s - project.sessionCount = s.length - }) - ) - } - if (!project.branches) { - loads.push( - loadBranches(project.path).then(b => { project.branches = b }).catch(() => { project.branches = [] }) - ) - } - if (loads.length > 0) await Promise.all(loads) - } - project.expanded = true - rebuildDisplayRows() - updateAll() -} - -async function doLaunch() { - if (selectedProjects.size === 0 && selectedSessions.size === 0) return - if (demoMode) { - selectedProjects.clear() - selectedSessions.clear() - selectedBranches.clear() - rebuildDisplayRows() - updateAll() - return - } - - // Build launch items - const items: { path: string; name: string; sessionId?: string; targetBranch?: string }[] = [] - - for (const path of selectedProjects) { - const project = projects.find(p => p.path === path) - if (!project) continue - const targetBranch = selectedBranches.get(path) - const needsBranch = targetBranch && targetBranch !== project.branch - // 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) { - if (!project.sessions) continue - for (const session of project.sessions) { - if (selectedSessions.has(session.id)) { - const targetBranch = selectedBranches.get(project.path) - const needsBranch = targetBranch && targetBranch !== project.branch - items.push({ path: project.path, name: project.name, sessionId: session.id, targetBranch: needsBranch ? targetBranch : undefined }) - } - } - } - - if (items.length === 0) return - - // Create PTY sessions and switch to grid view - ensureGridView() - - // DirectGridRenderer calculates pane sizes internally - const termW = process.stdout.columns || 120 - const termH = process.stdout.rows || 40 - const totalPanes = items.length + (directGrid?.paneCount || 0) - const cols = totalPanes <= 1 ? 1 : totalPanes <= 2 ? 2 : totalPanes <= 4 ? 2 : 3 - const rows = Math.ceil(totalPanes / cols) - const paneW = Math.max(Math.floor(termW / cols) - 2, 20) - const paneH = Math.max(Math.floor((termH - 2) / rows) - 4, 6) - - for (const item of items) { - const session = await createSession({ - projectPath: item.path, - projectName: item.name, - sessionId: item.sessionId, - targetBranch: item.targetBranch, - width: paneW, - height: paneH, - }) - await directGrid!.addPane(session) - } - - selectedProjects.clear() - selectedSessions.clear() - selectedBranches.clear() -} - -// ─── Grid View (Direct Renderer) ──────────────────────────────────── -function ensureGridView() { - if (viewMode === "grid" && directGrid) return - switchToGrid() -} - -function switchToGrid() { - viewMode = "grid" - - if (!directGrid) { - directGrid = new DirectGridRenderer(rawStdoutWrite) - } - - // Suspend OpenTUI render loop — this pauses stdin, exits raw mode, disables mouse - renderer.suspend() - - // Restore terminal state for direct rendering - if (process.stdin.isTTY) process.stdin.setRawMode(true) - process.stdin.resume() // Re-enable stdin data events (suspend pauses them) - rawStdoutWrite("\x1b[?1049h") // Alternate screen - // Enable mouse reporting in grid mode — only for scroll wheel and click-to-focus. - // With direct PTY (no tmux), mouse events stay in our stdin handler and never leak to subprocesses. - rawStdoutWrite("\x1b[?1000h") // Basic mouse tracking (clicks + scroll) - rawStdoutWrite("\x1b[?1006h") // SGR extended mouse coordinates - directGrid.start() -} - -function switchToPicker() { - viewMode = "picker" - if (directGrid) { - if (directGrid.selectMode) directGrid.exitSelectMode() - if (directGrid.paneCount > 0) directGrid.stop() - } - // Resume OpenTUI — it will re-enter alternate screen and redraw - renderer.resume() - // Remove any data listeners OpenTUI re-adds during resume (we handle stdin ourselves) - process.stdin.removeAllListeners("data") - process.stdin.on("data", stdinHandler) - // Re-enable mouse reporting (resume may not restore it) - process.stdout.write("\x1b[?1000h") - process.stdout.write("\x1b[?1006h") - if (mainBox) mainBox.visible = true - updateAll() - renderer.requestRender() -} - -function resizeGridPanes() { - if (!directGrid || directGrid.paneCount === 0) return - directGrid.repositionAll() -} - -async function handleGridInput(rawSequence: string): Promise { - if (viewMode !== "grid" || !directGrid) return false - - // Esc — collapse expanded pane back to grid - if (rawSequence === "\x1b" && directGrid.isExpanded) { - directGrid.collapsePane() - return true - } - - // Ctrl+` (0x1e) or ESC+` — return to picker - if (rawSequence === "\x1e" || rawSequence === "\x1b`") { - switchToPicker() - return true - } - - // Ctrl+N — focus next pane - if (rawSequence === "\x0e") { - directGrid.focusNext() - return true - } - - // Ctrl+P — focus previous pane - if (rawSequence === "\x10") { - directGrid.focusPrev() - return true - } - - // Ctrl+F — open focused pane's project in Finder - if (rawSequence === "\x06") { - const pane = directGrid.focusedPane - if (pane) Bun.spawn(["open", pane.session.projectPath]) - return true - } - - // Ctrl+W — close focused pane - if (rawSequence === "\x17") { - const pane = directGrid.focusedPane - if (pane) { - if (directGrid.isExpanded) directGrid.collapsePane() - const { killSession } = await import("./pty/session-manager") - directGrid.removePane(pane.session.name) - await killSession(pane.session.name) - if (directGrid.paneCount === 0) { - switchToPicker() - } - } - return true - } - - // PageUp / PageDown (Fn+Up/Down on Mac) — scroll focused pane - if (rawSequence === "\x1b[5~") { - directGrid.sendScrollToFocused("up") - return true - } - if (rawSequence === "\x1b[6~") { - directGrid.sendScrollToFocused("down") - return true - } - - // Forward everything else to the focused PTY pane - directGrid.sendInputToFocused(rawSequence) - return true -} - -// ─── Main ─────────────────────────────────────────────────────────── async function main() { process.stdout.write("\x1b[2J\x1b[H") process.stdout.write("\x1b[1m cladm\x1b[0m\n") - if (demoMode) { + if (app.demoMode) { process.stdout.write("\x1b[2m [Demo mode] Loading mock projects...\x1b[0m\n") - projects = generateMockProjects() + app.projects = generateMockProjects() } else { process.stdout.write("\x1b[2m Loading projects...\x1b[0m\n") - projects = await discoverProjects() - if (projects.length === 0) { + app.projects = await discoverProjects() + if (app.projects.length === 0) { console.log(" No projects found in ~/.claude/history.jsonl") process.exit(1) } process.stdout.write( - `\x1b[2m Found ${projects.length} projects. Loading git metadata...\x1b[0m\n` + `\x1b[2m Found ${app.projects.length} projects. Loading git metadata...\x1b[0m\n` ) - await Promise.all(projects.map((p) => loadGitMetadata(p))) + await Promise.all(app.projects.map((p) => loadGitMetadata(p))) } - sortedIndices = projects.map((_, i) => i) + app.sortedIndices = app.projects.map((_, i) => i) rebuildDisplayRows() // Save raw stdout.write BEFORE OpenTUI intercepts it - rawStdoutWrite = process.stdout.write.bind(process.stdout) as (s: string) => boolean + app.rawStdoutWrite = process.stdout.write.bind(process.stdout) as (s: string) => boolean - renderer = await createCliRenderer({ + app.renderer = await createCliRenderer({ exitOnCtrlC: true, useAlternateScreen: true, - useMouse: false, // We handle mouse ourselves to avoid OpenTUI consuming events + useMouse: false, onDestroy: () => { - destroyed = true - if (monitorInterval) { clearInterval(monitorInterval); monitorInterval = null } - if (directGrid) directGrid.destroyAll() + app.destroyed = true + if (app.monitorInterval) { clearInterval(app.monitorInterval); app.monitorInterval = null } + if (app.directGrid) app.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 + process.stdout.write("\x1b[?1000h") + process.stdout.write("\x1b[?1006h") // Build layout - mainBox = new BoxRenderable(renderer, { + app.mainBox = new BoxRenderable(app.renderer, { flexDirection: "column", width: "100%", height: "100%", }) - headerText = new TextRenderable(renderer, { + app.headerText = new TextRenderable(app.renderer, { width: "100%", height: 1, flexShrink: 0, }) - colHeaderText = new TextRenderable(renderer, { + app.colHeaderText = new TextRenderable(app.renderer, { width: "100%", height: 1, flexShrink: 0, }) - listBox = new ScrollBoxRenderable(renderer, { + app.listBox = new ScrollBoxRenderable(app.renderer, { scrollY: true, flexGrow: 1, viewportCulling: true, }) - bottomRow = new BoxRenderable(renderer, { + app.bottomRow = new BoxRenderable(app.renderer, { flexDirection: "row", height: 10, flexShrink: 0, width: "100%", }) - previewBox = new BoxRenderable(renderer, { + app.previewBox = new BoxRenderable(app.renderer, { flexGrow: 1, height: "100%", borderStyle: "single", @@ -1097,14 +105,14 @@ async function main() { paddingLeft: 0, }) - previewText = new TextRenderable(renderer, { + app.previewText = new TextRenderable(app.renderer, { width: "100%", flexGrow: 1, wrapMode: "word", }) - previewBox.add(previewText) + app.previewBox.add(app.previewText) - usageBox = new BoxRenderable(renderer, { + app.usageBox = new BoxRenderable(app.renderer, { width: 34, height: "100%", flexShrink: 0, @@ -1118,383 +126,119 @@ async function main() { paddingRight: 1, }) - bottomRow.add(previewBox) - bottomRow.add(usageBox) + app.bottomRow.add(app.previewBox) + app.bottomRow.add(app.usageBox) - footerText = new TextRenderable(renderer, { + app.footerText = new TextRenderable(app.renderer, { width: "100%", height: 1, flexShrink: 0, }) - mainBox.add(headerText) - mainBox.add(colHeaderText) - mainBox.add(listBox) - mainBox.add(bottomRow) - mainBox.add(footerText) + app.mainBox.add(app.headerText) + app.mainBox.add(app.colHeaderText) + app.mainBox.add(app.listBox) + app.mainBox.add(app.bottomRow) + app.mainBox.add(app.footerText) - renderer.root.add(mainBox) + app.renderer.root.add(app.mainBox) - updateHeader() updateColumnHeaders() - rebuildList() - updateBottomPanel() updateUsagePanel() - updateFooter() + updateAll() // Load initial usage data getUsageSummary().then(u => { - cachedUsage = u + app.cachedUsage = u updateUsagePanel() }).catch(() => {}) // Resize PTY panes when terminal window is resized process.stdout.on("resize", () => { - if (viewMode !== "grid" || !directGrid) return + if (app.viewMode !== "grid" || !app.directGrid) return resizeGridPanes() }) - // Shift+arrow sequences across terminal emulators - const SHIFT_ARROWS: Record = { - "\x1b[1;2A": "up", // xterm/iTerm2/most terminals - "\x1b[1;2B": "down", - "\x1b[1;2C": "right", - "\x1b[1;2D": "left", - "\x1b[a": "up", // rxvt - "\x1b[b": "down", - "\x1b[c": "right", - "\x1b[d": "left", - } - - // Take over stdin completely — removes OpenTUI's data listeners - // We parse all keys ourselves to avoid double-processing issues + // Take over stdin completely process.stdin.removeAllListeners("data") - - // Extract only safe keyboard input from stdin data. - // WHITELIST approach: only recognized keyboard sequences pass through. - // Everything else (mouse events, terminal responses, OSC, DCS, etc.) is dropped. - function extractKeyboardInput(data: string): string { - let keyboard = "" - let i = 0 - - while (i < data.length) { - const c = data.charCodeAt(i) - - // ESC sequences - if (c === 0x1b) { - if (i + 1 >= data.length) { keyboard += "\x1b"; i++; continue } // lone ESC = Escape key - - const next = data[i + 1] - - // OSC: \x1b] ... (terminated by BEL \x07 or ST \x1b\\) — drop entirely - if (next === "]") { - let j = i + 2 - while (j < data.length) { - if (data[j] === "\x07") { j++; break } - if (data[j] === "\x1b" && j + 1 < data.length && data[j + 1] === "\\") { j += 2; break } - j++ - } - i = j; continue - } - - // DCS: \x1bP ... ST | APC: \x1b_ ... ST | PM: \x1b^ ... ST — drop entirely - if (next === "P" || next === "_" || next === "^") { - let j = i + 2 - while (j < data.length) { - if (data[j] === "\x1b" && j + 1 < data.length && data[j + 1] === "\\") { j += 2; break } - j++ - } - i = j; continue - } - - // CSI: \x1b[ - if (next === "[") { - let j = i + 2 - // Consume parameter bytes (0x30-0x3F: digits, ;, <, =, >, ?) - while (j < data.length && data.charCodeAt(j) >= 0x30 && data.charCodeAt(j) <= 0x3F) j++ - // Consume intermediate bytes (0x20-0x2F) - while (j < data.length && data.charCodeAt(j) >= 0x20 && data.charCodeAt(j) <= 0x2F) j++ - // Final byte (0x40-0x7E) - if (j < data.length && data.charCodeAt(j) >= 0x40 && data.charCodeAt(j) <= 0x7E) { - const final = data[j] - // Legacy X10 mouse: \x1b[M followed by 3 raw bytes (btn+32, col+32, row+32) - // Must consume the 3 payload bytes or they leak as keyboard input - // (btn+32 for left click = 0x20 = ASCII space!) - if (final === "M" && j === i + 2) { - i = Math.min(j + 4, data.length); continue - } - // ONLY keep: arrows (A-D), Home (H), End (F), shift-tab (Z), function keys (~) - if ("ABCDHFZ~".includes(final)) { - keyboard += data.slice(i, j + 1) - } - i = j + 1; continue - } - // Incomplete/malformed CSI — drop - i = j; continue - } - - // SS3: \x1bO + letter (F1-F4, keypad) - if (next === "O" && i + 2 < data.length) { - keyboard += data.slice(i, i + 3) - i += 3; continue - } - - // \x1b` (ctrl+backtick) — keep as keyboard shortcut - if (next === "`") { - keyboard += "\x1b`" - i += 2; continue - } - - // Any other \x1b+char — drop (unknown escape sequence) - i += 2; continue - } - - // Regular character: printable ASCII, control chars, UTF-8 — keep - keyboard += data[i] - i++ - } - - return keyboard - } - - // Parse SGR mouse events from raw data. Returns array of {btn, col, row, release, consumed}. - function extractMouseEvents(data: string): { btn: number, col: number, row: number, release: boolean, start: number, end: number }[] { - const events: { btn: number, col: number, row: number, release: boolean, start: number, end: number }[] = [] - const re = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g - let m - while ((m = re.exec(data)) !== null) { - events.push({ - btn: parseInt(m[1]), - col: parseInt(m[2]), - row: parseInt(m[3]), - release: m[4] === "m", - start: m.index, - end: m.index + m[0].length, - }) - } - return events - } - - function stdinHandler(data: string | Buffer) { - const str = typeof data === "string" ? data : data.toString("utf8") - - if (viewMode === "grid" && directGrid) { - // In select mode, only listen for Esc to exit — everything else is native terminal - if (directGrid.selectMode) { - const keyboard = extractKeyboardInput(str) - if (keyboard === "\x1b") { - directGrid.exitSelectMode() - } - return - } - - // Handle mouse events first (scroll wheel + click-to-focus) - const mouseEvents = extractMouseEvents(str) - for (const me of mouseEvents) { - // Scroll: btn 64 = scroll up, btn 65 = scroll down - if (me.btn === 64) { - directGrid.sendScrollToFocused("up", 3) - continue - } - if (me.btn === 65) { - directGrid.sendScrollToFocused("down", 3) - continue - } - // Click (btn 0, press only): check buttons first, then focus pane - if (me.btn === 0 && !me.release) { - const btn = directGrid.checkButtonClick(me.col, me.row) - if (btn?.action === "max") { - directGrid.expandPane(btn.paneIndex) - } else if (btn?.action === "min") { - directGrid.collapsePane() - } else if (btn?.action === "sel") { - directGrid.enterSelectMode() - } else { - directGrid.focusByClick(me.col, me.row) - } - continue - } - } - - // Strip mouse events from the data before keyboard filtering - let stripped = str - // Remove mouse events from end to start to preserve indices - for (let i = mouseEvents.length - 1; i >= 0; i--) { - const me = mouseEvents[i] - stripped = stripped.slice(0, me.start) + stripped.slice(me.end) - } - - const keyboard = extractKeyboardInput(stripped) - if (keyboard) { - const dir = SHIFT_ARROWS[keyboard] - if (dir) { - directGrid.focusByDirection(dir) - } else { - handleGridInput(keyboard) - } - } - return - } - - // Picker mode: handle mouse clicks (scroll + click-to-select) - const pickerMouse = extractMouseEvents(str) - for (const me of pickerMouse) { - if (me.btn === 0 && !me.release) handlePickerClick(me.col, me.row) - if (me.btn === 64) { if (cursor > 0) { cursor--; updateAll() } } - if (me.btn === 65) { if (cursor < displayRows.length - 1) { cursor++; updateAll() } } - } - - // Parse keys directly (bypass OpenTUI pipeline to avoid double-firing) - const keyboard = extractKeyboardInput(str) - if (!keyboard) return - - // Map raw sequences to KeyEvent-like objects and call handleKeypress directly - const keyMap: Record = { - "\x1b[A": { name: "up" }, - "\x1b[B": { name: "down" }, - "\x1b[C": { name: "right" }, - "\x1b[D": { name: "left" }, - "\x1b[5~": { name: "pageup" }, - "\x1b[6~": { name: "pagedown" }, - "\x1b[H": { name: "home" }, - "\x1b[F": { name: "end" }, - "\x1bOH": { name: "home" }, - "\x1bOF": { name: "end" }, - "\x1b[Z": { name: "tab", shift: true }, - "\x1b[1;2A": { name: "up", shift: true }, - "\x1b[1;2B": { name: "down", shift: true }, - "\x1b[1;2C": { name: "right", shift: true }, - "\x1b[1;2D": { name: "left", shift: true }, - "\x09": { name: "tab" }, - "\x0d": { name: "return" }, - "\x1b": { name: "escape" }, - " ": { name: "space" }, - } - - // Parse sequences from the keyboard string - let ki = 0 - while (ki < keyboard.length) { - let matched = false - // Try longest match first (up to 8 chars for CSI sequences) - for (let len = Math.min(8, keyboard.length - ki); len >= 1; len--) { - const seq = keyboard.slice(ki, ki + len) - const mapped = keyMap[seq] - if (mapped) { - const syntheticKey = { - name: mapped.name, - shift: mapped.shift || false, - ctrl: mapped.ctrl || false, - meta: false, - preventDefault: () => {}, - stopPropagation: () => {}, - } as KeyEvent - handleKeypress(syntheticKey) - ki += len - matched = true - break - } - } - if (!matched) { - // Single printable character - const ch = keyboard[ki] - const code = ch.charCodeAt(0) - // Map printable ASCII to key name - if (code >= 0x21 && code <= 0x7e) { - const syntheticKey = { - name: ch, - shift: false, - ctrl: false, - meta: false, - preventDefault: () => {}, - stopPropagation: () => {}, - } as KeyEvent - handleKeypress(syntheticKey) - } - ki++ - } - } - } process.stdin.on("data", stdinHandler) // Live session monitoring - if (demoMode) { - generateMockActiveSessions(projects) - generateMockBusySessions(projects) - for (const p of projects) { + if (app.demoMode) { + generateMockActiveSessions(app.projects) + generateMockBusySessions(app.projects) + for (const p of app.projects) { if (p.activeSessions > 0 && !p.sessions) { p.sessions = generateMockSessions(p.path) p.sessionCount = p.sessions.length } populateMockSessionStatus(p) } - prevBusySnapshot = snapshotBusy(projects) + app.prevBusySnapshot = snapshotBusy(app.projects) updateAll() } else { detectActiveSessions().then((sessions) => { - if (updateProjectSessions(projects, sessions)) updateAll() - prevBusySnapshot = snapshotBusy(projects) + if (updateProjectSessions(app.projects, sessions)) updateAll() + app.prevBusySnapshot = snapshotBusy(app.projects) }) } let usageTick = 0 - monitorInterval = setInterval(async () => { - if (destroyed) return + app.monitorInterval = setInterval(async () => { + if (app.destroyed) return - // Refresh usage every ~30s (6 ticks of 5s) usageTick++ if (usageTick % 6 === 0) { try { - cachedUsage = await getUsageSummary() + app.cachedUsage = await getUsageSummary() updateUsagePanel() } catch {} } - if (demoMode) { - for (const p of projects) { p.activeSessions = 0; p.busySessions = 0 } - generateMockActiveSessions(projects) - generateMockBusySessions(projects) - for (const p of projects) { + if (app.demoMode) { + for (const p of app.projects) { p.activeSessions = 0; p.busySessions = 0 } + generateMockActiveSessions(app.projects) + generateMockBusySessions(app.projects) + for (const p of app.projects) { if (p.activeSessions > 0 && !p.sessions) { p.sessions = generateMockSessions(p.path) p.sessionCount = p.sessions.length } populateMockSessionStatus(p) } - const transitioned = checkTransitions(projects, prevBusySnapshot) - prevBusySnapshot = snapshotBusy(projects) + const transitioned = checkTransitions(app.projects, app.prevBusySnapshot) + app.prevBusySnapshot = snapshotBusy(app.projects) if (transitioned.length > 0) { playDoneSound() bounceDock() - bottomPanelMode = "idle" + app.bottomPanelMode = "idle" } updateAll() } else { const sessions = await detectActiveSessions() - const changed = updateProjectSessions(projects, sessions) - const transitioned = checkTransitions(projects, prevBusySnapshot) - // Eagerly load/refresh session data for active projects (needed for idle panel) - for (const p of projects) { + const changed = updateProjectSessions(app.projects, sessions) + const transitioned = checkTransitions(app.projects, app.prevBusySnapshot) + for (const p of app.projects) { if (p.activeSessions > 0 && (!p.sessions || transitioned.length > 0)) { p.sessions = await loadSessions(p.path) p.sessionCount = p.sessions.length } } - prevBusySnapshot = snapshotBusy(projects) + app.prevBusySnapshot = snapshotBusy(app.projects) if (transitioned.length > 0) { playDoneSound() bounceDock() - bottomPanelMode = "idle" + app.bottomPanelMode = "idle" } if (changed) updateAll() - // Update grid pane statuses (flash idle sessions) - if (directGrid && viewMode === "grid") { + if (app.directGrid && app.viewMode === "grid") { await refreshAlive() for (const [, s] of getSessions()) { const status = getSessionStatus(s.projectPath, s.sessionId) - if (status === "idle") directGrid.markIdle(s.name) - else if (status === "busy") directGrid.markBusy(s.name) - else directGrid.clearMark(s.name) + if (status === "idle") app.directGrid.markIdle(s.name) + else if (status === "busy") app.directGrid.markBusy(s.name) + else app.directGrid.clearMark(s.name) } } } diff --git a/src/input/handlers.ts b/src/input/handlers.ts new file mode 100644 index 0000000..592ba12 --- /dev/null +++ b/src/input/handlers.ts @@ -0,0 +1,492 @@ +import type { KeyEvent } from "@opentui/core" +import { app } from "../lib/state" +import { updateAll, rebuildDisplayRows, applySortMode } from "../ui/panels" +import { extractKeyboardInput, extractMouseEvents } from "./parser" +import { switchToGrid } from "../grid/view-switch" +import { doLaunch } from "../actions/launch" +import { launchSelections } from "../actions/launcher" +import { loadSessions } from "../data/sessions" +import { loadBranches } from "../data/git" +import { generateMockSessions, generateMockBranches } from "../data/mock" +import { focusTerminalByPath, getSessionStatus, populateMockSessionStatus } from "../data/monitor" +import { stopAllCaptures } from "../pty/capture" + +// Shift+arrow sequences across terminal emulators +const SHIFT_ARROWS: Record = { + "\x1b[1;2A": "up", + "\x1b[1;2B": "down", + "\x1b[1;2C": "right", + "\x1b[1;2D": "left", + "\x1b[a": "up", + "\x1b[b": "down", + "\x1b[c": "right", + "\x1b[d": "left", +} + +export async function expandProject(projectIndex: number) { + const project = app.projects[projectIndex] + if (app.demoMode) { + if (!project.sessions) { + project.sessions = generateMockSessions(project.path) + project.sessionCount = project.sessions.length + } + if (!project.branches) { + project.branches = generateMockBranches(project.path) + } + populateMockSessionStatus(project) + } else { + const loads: Promise[] = [] + if (!project.sessions) { + loads.push( + loadSessions(project.path).then(s => { + project.sessions = s + project.sessionCount = s.length + }) + ) + } + if (!project.branches) { + loads.push( + loadBranches(project.path).then(b => { project.branches = b }).catch(() => { project.branches = [] }) + ) + } + if (loads.length > 0) await Promise.all(loads) + } + project.expanded = true + rebuildDisplayRows() + updateAll() +} + +export function hitTestListRow(screenRow: number): number { + const listStartY = 2 + const relY = screenRow - listStartY + app.listBox.scrollTop + if (relY < 0) return -1 + + let y = 0 + for (let i = 0; i < app.displayRows.length; i++) { + const h = app.displayRows[i].type === "session" ? 3 : 1 + if (relY >= y && relY < y + h) return i + y += h + } + return -1 +} + +export function handlePickerClick(_col: number, screenRow: number) { + const idx = hitTestListRow(screenRow) + if (idx < 0 || idx >= app.displayRows.length) return + + app.cursor = idx + const row = app.displayRows[idx] + const project = app.projects[row.projectIndex] + + if (row.type === "project" || row.type === "new-session") { + const path = project.path + if (app.selectedProjects.has(path)) app.selectedProjects.delete(path) + else app.selectedProjects.add(path) + } else if (row.type === "session") { + const session = project.sessions![row.sessionIndex!] + if (app.selectedSessions.has(session.id)) app.selectedSessions.delete(session.id) + else app.selectedSessions.add(session.id) + } else if (row.type === "branch") { + const path = project.path + if (app.selectedBranches.get(path) === row.branchName) { + app.selectedBranches.delete(path) + } else { + app.selectedBranches.set(path, row.branchName!) + } + } + + updateAll() +} + +export async function handleKeypress(key: KeyEvent) { + try { + const total = app.displayRows.length + if (total === 0) return + + switch (key.name) { + case "up": + if (app.cursor > 0) app.cursor-- + break + + case "down": + if (app.cursor < total - 1) app.cursor++ + break + + case "pageup": + app.cursor = Math.max(0, app.cursor - 15) + break + + case "pagedown": + app.cursor = Math.min(total - 1, app.cursor + 15) + break + + case "home": + app.cursor = 0 + break + + case "end": + app.cursor = total - 1 + break + + case "right": { + const row = app.displayRows[app.cursor] + if (row.type === "project") { + const project = app.projects[row.projectIndex] + if (!project.expanded) { + expandProject(row.projectIndex) + return + } + } + return + } + + case "left": { + const row = app.displayRows[app.cursor] + if (row.type === "project") { + app.projects[row.projectIndex].expanded = false + } else { + app.projects[row.projectIndex].expanded = false + const target = row.projectIndex + rebuildDisplayRows() + app.cursor = app.displayRows.findIndex( + (r) => r.type === "project" && r.projectIndex === target + ) + if (app.cursor < 0) app.cursor = 0 + } + rebuildDisplayRows() + if (app.cursor >= app.displayRows.length) app.cursor = app.displayRows.length - 1 + break + } + + case "space": { + const row = app.displayRows[app.cursor] + if (row.type === "project" || row.type === "new-session") { + const path = app.projects[row.projectIndex].path + if (app.selectedProjects.has(path)) app.selectedProjects.delete(path) + else app.selectedProjects.add(path) + } else if (row.type === "session") { + const session = app.projects[row.projectIndex].sessions![row.sessionIndex!] + if (app.selectedSessions.has(session.id)) app.selectedSessions.delete(session.id) + else app.selectedSessions.add(session.id) + } else if (row.type === "branch") { + const path = app.projects[row.projectIndex].path + if (app.selectedBranches.get(path) === row.branchName) { + app.selectedBranches.delete(path) + } else { + app.selectedBranches.set(path, row.branchName!) + } + } + break + } + + case "f": { + const row = app.displayRows[app.cursor] + const project = app.projects[row.projectIndex] + Bun.spawn(["open", project.path]) + break + } + + case "g": { + const row = app.displayRows[app.cursor] + const project = app.projects[row.projectIndex] + if (project.activeSessions > 0) { + const sid = row.type === "session" && project.sessions + ? project.sessions[row.sessionIndex!]?.id + : undefined + await focusTerminalByPath(project.path, sid) + } + return + } + + case "a": + for (const p of app.projects) app.selectedProjects.add(p.path) + break + + case "n": + app.selectedProjects.clear() + app.selectedSessions.clear() + app.selectedBranches.clear() + break + + case "i": + app.bottomPanelMode = app.bottomPanelMode === "preview" ? "idle" : "preview" + app.idleCursor = 0 + break + + case "tab": + if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0) { + if (key.shift) { + app.idleCursor = app.idleCursor > 0 ? app.idleCursor - 1 : Math.min(app.cachedIdleSessions.length, 3) - 1 + } else { + app.idleCursor = (app.idleCursor + 1) % Math.min(app.cachedIdleSessions.length, 3) + } + } + break + + case "s": + app.sortMode = (app.sortMode + 1) % app.sortLabels.length + applySortMode() + app.cursor = 0 + break + + case "return": { + const hasSelections = app.selectedProjects.size > 0 || app.selectedSessions.size > 0 + if (hasSelections) { + doLaunch() + break + } + if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0 && app.idleCursor < app.cachedIdleSessions.length) { + const focused = await focusTerminalByPath(app.cachedIdleSessions[app.idleCursor].projectPath) + if (focused) return + } + const returnRow = app.displayRows[app.cursor] + if ( + returnRow.type === "project" && + app.projects[returnRow.projectIndex].activeSessions > 0 + ) { + const focused = await focusTerminalByPath(app.projects[returnRow.projectIndex].path) + if (focused) return + } + doLaunch() + break + } + + case "o": { + const hasOSel = app.selectedProjects.size > 0 || app.selectedSessions.size > 0 + if (!hasOSel) { + const oRow = app.displayRows[app.cursor] + if (oRow) app.selectedProjects.add(app.projects[oRow.projectIndex].path) + } + if (app.selectedProjects.size > 0 || app.selectedSessions.size > 0) { + await launchSelections(app.projects, app.selectedProjects, app.selectedSessions, app.selectedBranches) + app.selectedProjects.clear() + app.selectedSessions.clear() + app.selectedBranches.clear() + } + break + } + + case "q": + case "escape": + app.destroyed = true + if (app.monitorInterval) clearInterval(app.monitorInterval) + stopAllCaptures() + process.stdout.write("\x1b[?1006l") + process.stdout.write("\x1b[?1000l") + app.renderer.destroy() + return + + case "t": + if (app.directGrid && app.directGrid.paneCount > 0) { + switchToGrid() + return + } + return + + default: + return + } + + updateAll() + } catch {} +} + +export async function handleGridInput(rawSequence: string): Promise { + if (app.viewMode !== "grid" || !app.directGrid) return false + + if (rawSequence === "\x1b" && app.directGrid.isExpanded) { + app.directGrid.collapsePane() + return true + } + + if (rawSequence === "\x1e" || rawSequence === "\x1b`") { + switchToPicker() + return true + } + + if (rawSequence === "\x0e") { + app.directGrid.focusNext() + return true + } + + if (rawSequence === "\x10") { + app.directGrid.focusPrev() + return true + } + + if (rawSequence === "\x06") { + const pane = app.directGrid.focusedPane + if (pane) Bun.spawn(["open", pane.session.projectPath]) + return true + } + + if (rawSequence === "\x17") { + const pane = app.directGrid.focusedPane + if (pane) { + if (app.directGrid.isExpanded) app.directGrid.collapsePane() + const { killSession } = await import("../pty/session-manager") + app.directGrid.removePane(pane.session.name) + await killSession(pane.session.name) + if (app.directGrid.paneCount === 0) { + switchToPicker() + } + } + return true + } + + if (rawSequence === "\x1b[5~") { + app.directGrid.sendScrollToFocused("up") + return true + } + if (rawSequence === "\x1b[6~") { + app.directGrid.sendScrollToFocused("down") + return true + } + + app.directGrid.sendInputToFocused(rawSequence) + return true +} + +export function switchToPicker() { + app.viewMode = "picker" + if (app.directGrid) { + if (app.directGrid.selectMode) app.directGrid.exitSelectMode() + if (app.directGrid.paneCount > 0) app.directGrid.stop() + } + app.renderer.resume() + process.stdin.removeAllListeners("data") + process.stdin.on("data", stdinHandler) + process.stdout.write("\x1b[?1000h") + process.stdout.write("\x1b[?1006h") + if (app.mainBox) app.mainBox.visible = true + updateAll() + app.renderer.requestRender() +} + +export function stdinHandler(data: string | Buffer) { + const str = typeof data === "string" ? data : data.toString("utf8") + + if (app.viewMode === "grid" && app.directGrid) { + if (app.directGrid.selectMode) { + const keyboard = extractKeyboardInput(str) + if (keyboard === "\x1b") { + app.directGrid.exitSelectMode() + } + return + } + + const mouseEvents = extractMouseEvents(str) + for (const me of mouseEvents) { + if (me.btn === 64) { + app.directGrid.sendScrollToFocused("up", 3) + continue + } + if (me.btn === 65) { + app.directGrid.sendScrollToFocused("down", 3) + continue + } + if (me.btn === 0 && !me.release) { + const btn = app.directGrid.checkButtonClick(me.col, me.row) + if (btn?.action === "max") { + app.directGrid.expandPane(btn.paneIndex) + } else if (btn?.action === "min") { + app.directGrid.collapsePane() + } else if (btn?.action === "sel") { + app.directGrid.enterSelectMode() + } else { + app.directGrid.focusByClick(me.col, me.row) + } + continue + } + } + + let stripped = str + for (let i = mouseEvents.length - 1; i >= 0; i--) { + const me = mouseEvents[i] + stripped = stripped.slice(0, me.start) + stripped.slice(me.end) + } + + const keyboard = extractKeyboardInput(stripped) + if (keyboard) { + const dir = SHIFT_ARROWS[keyboard] + if (dir) { + app.directGrid.focusByDirection(dir) + } else { + handleGridInput(keyboard) + } + } + return + } + + // Picker mode + 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 (app.cursor > 0) { app.cursor--; updateAll() } } + if (me.btn === 65) { if (app.cursor < app.displayRows.length - 1) { app.cursor++; updateAll() } } + } + + const keyboard = extractKeyboardInput(str) + if (!keyboard) return + + const keyMap: Record = { + "\x1b[A": { name: "up" }, + "\x1b[B": { name: "down" }, + "\x1b[C": { name: "right" }, + "\x1b[D": { name: "left" }, + "\x1b[5~": { name: "pageup" }, + "\x1b[6~": { name: "pagedown" }, + "\x1b[H": { name: "home" }, + "\x1b[F": { name: "end" }, + "\x1bOH": { name: "home" }, + "\x1bOF": { name: "end" }, + "\x1b[Z": { name: "tab", shift: true }, + "\x1b[1;2A": { name: "up", shift: true }, + "\x1b[1;2B": { name: "down", shift: true }, + "\x1b[1;2C": { name: "right", shift: true }, + "\x1b[1;2D": { name: "left", shift: true }, + "\x09": { name: "tab" }, + "\x0d": { name: "return" }, + "\x1b": { name: "escape" }, + " ": { name: "space" }, + } + + let ki = 0 + while (ki < keyboard.length) { + let matched = false + 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) { + const ch = keyboard[ki] + const code = ch.charCodeAt(0) + if (code >= 0x21 && code <= 0x7e) { + const syntheticKey = { + name: ch, + shift: false, + ctrl: false, + meta: false, + preventDefault: () => {}, + stopPropagation: () => {}, + } as KeyEvent + handleKeypress(syntheticKey) + } + ki++ + } + } +} diff --git a/src/input/parser.ts b/src/input/parser.ts new file mode 100644 index 0000000..c1b0b2a --- /dev/null +++ b/src/input/parser.ts @@ -0,0 +1,102 @@ +// Extract only safe keyboard input from stdin data. +// WHITELIST approach: only recognized keyboard sequences pass through. +// Everything else (mouse events, terminal responses, OSC, DCS, etc.) is dropped. +export function extractKeyboardInput(data: string): string { + let keyboard = "" + let i = 0 + + while (i < data.length) { + const c = data.charCodeAt(i) + + // ESC sequences + if (c === 0x1b) { + if (i + 1 >= data.length) { keyboard += "\x1b"; i++; continue } // lone ESC = Escape key + + const next = data[i + 1] + + // OSC: \x1b] ... (terminated by BEL \x07 or ST \x1b\\) — drop entirely + if (next === "]") { + let j = i + 2 + while (j < data.length) { + if (data[j] === "\x07") { j++; break } + if (data[j] === "\x1b" && j + 1 < data.length && data[j + 1] === "\\") { j += 2; break } + j++ + } + i = j; continue + } + + // DCS: \x1bP ... ST | APC: \x1b_ ... ST | PM: \x1b^ ... ST — drop entirely + if (next === "P" || next === "_" || next === "^") { + let j = i + 2 + while (j < data.length) { + if (data[j] === "\x1b" && j + 1 < data.length && data[j + 1] === "\\") { j += 2; break } + j++ + } + i = j; continue + } + + // CSI: \x1b[ + if (next === "[") { + let j = i + 2 + // Consume parameter bytes (0x30-0x3F: digits, ;, <, =, >, ?) + while (j < data.length && data.charCodeAt(j) >= 0x30 && data.charCodeAt(j) <= 0x3F) j++ + // Consume intermediate bytes (0x20-0x2F) + while (j < data.length && data.charCodeAt(j) >= 0x20 && data.charCodeAt(j) <= 0x2F) j++ + // Final byte (0x40-0x7E) + if (j < data.length && data.charCodeAt(j) >= 0x40 && data.charCodeAt(j) <= 0x7E) { + const final = data[j] + // Legacy X10 mouse: \x1b[M followed by 3 raw bytes (btn+32, col+32, row+32) + if (final === "M" && j === i + 2) { + i = Math.min(j + 4, data.length); continue + } + // ONLY keep: arrows (A-D), Home (H), End (F), shift-tab (Z), function keys (~) + if ("ABCDHFZ~".includes(final)) { + keyboard += data.slice(i, j + 1) + } + i = j + 1; continue + } + // Incomplete/malformed CSI — drop + i = j; continue + } + + // SS3: \x1bO + letter (F1-F4, keypad) + if (next === "O" && i + 2 < data.length) { + keyboard += data.slice(i, i + 3) + i += 3; continue + } + + // \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. +export function extractMouseEvents(data: string): { btn: number, col: number, row: number, release: boolean, start: number, end: number }[] { + const events: { btn: number, col: number, row: number, release: boolean, start: number, end: number }[] = [] + const re = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g + let m + while ((m = re.exec(data)) !== null) { + events.push({ + btn: parseInt(m[1]), + col: parseInt(m[2]), + row: parseInt(m[3]), + release: m[4] === "m", + start: m.index, + end: m.index + m[0].length, + }) + } + return events +} diff --git a/src/lib/state.ts b/src/lib/state.ts new file mode 100644 index 0000000..66867b9 --- /dev/null +++ b/src/lib/state.ts @@ -0,0 +1,50 @@ +import type { CliRenderer } from "@opentui/core" +import type { BoxRenderable, TextRenderable, ScrollBoxRenderable } from "@opentui/core" +import type { Project, DisplayRow } from "./types" +import type { DirectGridRenderer } from "../components/direct-grid" +import type { UsageSummary } from "../data/usage" +import type { IdleSessionInfo } from "../data/monitor" + +export type ViewMode = "picker" | "grid" + +export const app = { + // Config + demoMode: Bun.argv.includes("--demo"), + + // Data + projects: [] as Project[], + selectedProjects: new Set(), + selectedSessions: new Set(), + selectedBranches: new Map(), + cursor: 0, + sortMode: 0, + sortLabels: ["recent", "name", "commit", "sessions"] as const, + sortedIndices: [] as number[], + displayRows: [] as DisplayRow[], + + // Monitor + monitorInterval: null as ReturnType | null, + prevBusySnapshot: new Map(), + bottomPanelMode: "preview" as "preview" | "idle", + destroyed: false, + idleCursor: 0, + cachedIdleSessions: [] as IdleSessionInfo[], + + // Grid mode + viewMode: "picker" as ViewMode, + directGrid: null as DirectGridRenderer | null, + mainBox: null as BoxRenderable | null, + rawStdoutWrite: null as unknown as (s: string) => boolean, + + // UI refs (set during init) + renderer: null as unknown as CliRenderer, + headerText: null as unknown as TextRenderable, + colHeaderText: null as unknown as TextRenderable, + listBox: null as unknown as ScrollBoxRenderable, + bottomRow: null as unknown as BoxRenderable, + previewBox: null as unknown as BoxRenderable, + previewText: null as unknown as TextRenderable, + usageBox: null as unknown as BoxRenderable, + footerText: null as unknown as TextRenderable, + cachedUsage: null as UsageSummary | null, +} diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..7aeb86a --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,4 @@ +export const CURSOR_BG = "#283457" +export const ACTIVE_BG = "#1a2e1a" +export const ACCENT = "#7aa2f7" +export const DIM_CLR = "#565f89" diff --git a/src/ui/formatters.ts b/src/ui/formatters.ts new file mode 100644 index 0000000..9e8d475 --- /dev/null +++ b/src/ui/formatters.ts @@ -0,0 +1,144 @@ +import { + t, + bold, + dim, + fg, + green, + yellow, + cyan, + magenta, +} from "@opentui/core" +import { app } from "../lib/state" +import { ACCENT } from "../lib/theme" +import { getSessionStatus } from "../data/monitor" +import { timeAgo, formatSize, elapsedCompact } from "../lib/time" + +export function fmtSyncIndicator(ahead: number, behind: number): string { + if (ahead === -1 && behind === -1) return "✗" + if (ahead === 0 && behind === 0) return "✓" + const parts: string[] = [] + if (ahead > 0) parts.push(`↑${ahead}`) + if (behind > 0) parts.push(`↓${behind}`) + return parts.join("") +} + +export function fmtProjectRow(project: import("../lib/types").Project, isSelected: boolean) { + let activeDot: string + let activeTag: string + if (project.activeSessions > 0) { + if (project.busySessions > 0) { + activeDot = green("●") + const count = String(project.activeSessions) + activeTag = project.activeSessions > 1 ? yellow((count + " ").slice(0, 2)) : " " + } else { + activeDot = yellow("◉") + const elapsed = elapsedCompact(project.lastActivityMs) + activeTag = elapsed ? dim((elapsed + " ").slice(0, 2)) : " " + } + } else { + activeDot = dim("○") + activeTag = " " + } + const check = isSelected ? green("✓") : " " + const arrow = project.expanded ? "▼" : "▶" + const name = + project.name.length > 28 ? project.name.slice(0, 25) + "..." : project.name + const branch = + project.branch.length > 8 + ? project.branch.slice(0, 7) + "…" + : project.branch + + const sync = fmtSyncIndicator(project.ahead, project.behind) + const syncCol = sync === "✓" ? green(sync.padEnd(5)) + : sync === "✗" ? dim(sync.padEnd(5)) + : yellow(sync.padEnd(5)) + + const dirtyCol = project.dirty + ? yellow(project.dirty.padEnd(9)) + : green("clean".padEnd(9)) + + const ca = project.claudeAgo + let claudeCol + if (ca === "never" || ca === "-") claudeCol = dim(ca.padEnd(9)) + else if (ca.includes("m ago") || ca.includes("h ago") || ca === "just now") + claudeCol = cyan(ca.padEnd(9)) + else if (ca.includes("d ago")) claudeCol = green(ca.padEnd(9)) + else claudeCol = dim(ca.padEnd(9)) + + return t` ${activeDot}${activeTag}[${check}] ${dim(arrow)} ${name.padEnd(28)} ${magenta(branch.padEnd(9))}${syncCol}${dim( + (project.commitAge || "-").padEnd(10) + )}${(project.commitMsg || "-").padEnd(22)}${dirtyCol}${claudeCol}${dim( + String(project.sessionCount).padStart(3) + )} ${dim(String(project.totalMessages).padStart(5))} ${dim(project.tags)}` +} + +export function fmtSessionRow( + projectIdx: number, + sessionIdx: number, + isSelected: boolean, + isLastSession: boolean +) { + const project = app.projects[projectIdx] + const session = project.sessions![sessionIdx] + const check = isSelected ? green("✓") : " " + const prefix = isLastSession ? "│ " : "├─" + const title = + session.title.length > 55 + ? session.title.slice(0, 52) + "..." + : session.title + const age = timeAgo(session.timestamp) + const size = formatSize(session.sizeBytes) + + const status = getSessionStatus(project.path, session.id) + + const promptText = session.lastUserPrompt + ? session.lastUserPrompt.length > 60 + ? session.lastUserPrompt.slice(0, 57) + "..." + : session.lastUserPrompt + : "(no text)" + const responseText = session.lastAssistantMsg + ? session.lastAssistantMsg.length > 60 + ? session.lastAssistantMsg.slice(0, 57) + "..." + : session.lastAssistantMsg + : "(no text response)" + + if (status === "busy") { + return t` ${green("●")} ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim( + size.padEnd(7) + )} ${fg(ACCENT)('"' + title + '"')} ${green("running")} + ${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')} + ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}` + } + if (status === "idle") { + return t` ${yellow("◉")} ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim( + size.padEnd(7) + )} ${fg(ACCENT)('"' + title + '"')} ${yellow("idle")} + ${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')} + ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}` + } + return t` ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim( + size.padEnd(7) + )} ${fg(ACCENT)('"' + title + '"')} + ${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')} + ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}` +} + +export function fmtNewSessionRow(projectIdx: number, isSelected: boolean) { + const check = isSelected ? green("✓") : " " + return t` ${dim("└─")} [${check}] ${green("+ New session")}` +} + +export function fmtBranchRow(projectIdx: number, branchName: string, isSelected: boolean) { + const project = app.projects[projectIdx] + const br = project.branches?.find(b => b.name === branchName) + if (!br) return t` ${dim("├─")} ${branchName}` + + const check = isSelected ? green("✓") : " " + const sync = fmtSyncIndicator(br.ahead, br.behind) + const syncCol = sync === "✓" ? green(sync) + : sync === "✗" ? dim(sync) + : yellow(sync) + const msg = br.lastCommitMsg.length > 40 ? br.lastCommitMsg.slice(0, 37) + "..." : br.lastCommitMsg + + return t` ${dim("├─")} [${check}] ${magenta(branchName.padEnd(18))} ${syncCol} ${dim(br.lastCommitAge.padEnd(9))} ${dim(msg)}` +} diff --git a/src/ui/panels.ts b/src/ui/panels.ts new file mode 100644 index 0000000..dd2049d --- /dev/null +++ b/src/ui/panels.ts @@ -0,0 +1,321 @@ +import { + Box, + Text, + t, + bold, + dim, + fg, + green, + yellow, + cyan, + magenta, +} from "@opentui/core" +import { app } from "../lib/state" +import { CURSOR_BG, ACTIVE_BG, ACCENT, DIM_CLR } from "../lib/theme" +import { getSessionStatus, getIdleSessions } from "../data/monitor" +import { getUsageSummary, formatCost, formatWindow, makeBar, pct, PLAN_LIMITS } from "../data/usage" +import { timeAgo, formatSize, elapsedCompact } from "../lib/time" +import { fmtProjectRow, fmtSessionRow, fmtNewSessionRow, fmtBranchRow, fmtSyncIndicator } from "./formatters" + +export function rebuildDisplayRows() { + app.displayRows = [] + for (const idx of app.sortedIndices) { + const project = app.projects[idx] + app.displayRows.push({ type: "project", projectIndex: idx }) + if (project.expanded) { + if (project.branches) { + for (const br of project.branches) { + if (!br.isCurrent) { + app.displayRows.push({ type: "branch", projectIndex: idx, branchName: br.name }) + } + } + } + if (project.sessions) { + for (let si = 0; si < project.sessions.length; si++) { + app.displayRows.push({ type: "session", projectIndex: idx, sessionIndex: si }) + } + } + app.displayRows.push({ type: "new-session", projectIndex: idx }) + } + } +} + +export function applySortMode() { + const indices = Array.from(app.projects.keys()) + switch (app.sortMode) { + case 0: + app.sortedIndices = indices + break + case 1: + app.sortedIndices = indices.sort((a, b) => + app.projects[a].name.localeCompare(app.projects[b].name) + ) + break + case 2: + app.sortedIndices = indices.sort( + (a, b) => (app.projects[b].commitEpoch || 0) - (app.projects[a].commitEpoch || 0) + ) + break + case 3: + app.sortedIndices = indices.sort( + (a, b) => app.projects[b].sessionCount - app.projects[a].sessionCount + ) + break + } + rebuildDisplayRows() +} + +export function updateHeader() { + const total = app.selectedProjects.size + app.selectedSessions.size + const branchNote = app.selectedBranches.size > 0 ? ` (${app.selectedBranches.size} branch switch)` : "" + const modeLabel = app.demoMode ? " [DEMO]" : "" + const activeCount = app.projects.reduce((sum, p) => sum + (p.activeSessions > 0 ? 1 : 0), 0) + const busyCount = app.projects.reduce((sum, p) => sum + (p.busySessions > 0 ? 1 : 0), 0) + const idleCount = activeCount - busyCount + if (activeCount > 0) { + app.headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${String(total)} selected${branchNote} ${dim( + `sort: ${app.sortLabels[app.sortMode]} │ ${app.projects.length} projects` + )} │ ${green(`${busyCount} busy`)} ${yellow(`${idleCount} idle`)}` + } else { + app.headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${String(total)} selected${branchNote} ${dim( + `sort: ${app.sortLabels[app.sortMode]} │ ${app.projects.length} projects` + )}` + } +} + +export function updateColumnHeaders() { + const cols = ` ${"PROJECT".padEnd(30)} ${"BRANCH".padEnd(9)}${"SYNC".padEnd(5)}${"COMMIT".padEnd(10)}${"MESSAGE".padEnd(22)}${"DIRTY".padEnd(9)}${"LAST USE".padEnd(9)}${"SES".padStart(3)} ${"MSGS".padStart(5)} STACK` + app.colHeaderText.content = t` ${dim(cols)}` +} + +function addIdleRow(s: { idleSinceMs: number; projectName: string; sessionTitle: string; lastPrompt: string; lastResponse: string }, isCursor: boolean) { + const elapsed = (elapsedCompact(s.idleSinceMs) || "<5s").padEnd(6) + const name = s.projectName.length > 20 ? s.projectName.slice(0, 17) + "..." : s.projectName + const title = s.sessionTitle.length > 50 ? s.sessionTitle.slice(0, 47) + "..." : s.sessionTitle + const prompt = s.lastPrompt + ? s.lastPrompt.length > 60 ? s.lastPrompt.slice(0, 57) + "..." : s.lastPrompt + : "(no text)" + const response = s.lastResponse + ? s.lastResponse.length > 60 ? s.lastResponse.slice(0, 57) + "..." : s.lastResponse + : "(no response)" + const pointer = isCursor ? "▸" : " " + app.previewBox.add(Text({ content: t` ${yellow("◉")} ${isCursor ? cyan(pointer) : dim(pointer)} ${dim(elapsed)}${bold(name)} ${fg(ACCENT)('"' + title + '"')}`, width: "100%", height: 1 })) + app.previewBox.add(Text({ content: t` ${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + prompt + '"')}`, width: "100%", height: 1 })) + app.previewBox.add(Text({ content: t` ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + response + '"')}`, width: "100%", height: 1 })) +} + +export function updateIdlePanel() { + app.cachedIdleSessions = getIdleSessions(app.projects) + const n = app.cachedIdleSessions.length + app.previewBox.title = ` Idle Sessions (${n}) — enter to focus ` + for (const child of app.previewBox.getChildren()) app.previewBox.remove(child.id) + if (n === 0) { + app.idleCursor = 0 + app.previewBox.add(Text({ content: t`${dim(" No idle sessions")}`, width: "100%", height: 1 })) + return + } + if (app.idleCursor >= n) app.idleCursor = n - 1 + const show = app.cachedIdleSessions.slice(0, 3) + for (let i = 0; i < show.length; i++) { + addIdleRow(show[i], app.idleCursor === i) + } + if (n > 3) { + app.previewBox.add(Text({ content: t` ${dim(`+${n - 3} more`)}`, width: "100%", height: 1 })) + } +} + +export function updateBottomPanel() { + if (app.bottomPanelMode === "idle") { + app.bottomRow.height = 14 + updateIdlePanel() + } else { + for (const child of app.previewBox.getChildren()) app.previewBox.remove(child.id) + app.previewBox.add(app.previewText) + app.bottomRow.height = 10 + app.previewBox.title = " Preview " + updatePreview() + } +} + +function usageBarColor(p: number) { + return p >= 80 ? yellow : p >= 50 ? cyan : green +} + +export function updateUsagePanel() { + if (app.destroyed) return + for (const child of app.usageBox.getChildren()) app.usageBox.remove(child.id) + + if (!app.cachedUsage) { + app.usageBox.title = " Usage " + app.usageBox.add(Text({ content: t`${dim("Loading...")}`, width: "100%", height: 1 })) + return + } + + const u = app.cachedUsage + const BAR_W = 18 + + const sPct = pct(u.totalCost, PLAN_LIMITS.session) + const sBar = makeBar(u.totalCost, PLAN_LIMITS.session, BAR_W) + const sReset = u.sessionResetMs > 0 ? formatWindow(u.sessionResetMs) : "" + app.usageBox.title = " Usage " + app.usageBox.add(Text({ content: t`${bold("Session")}`, width: "100%", height: 1 })) + app.usageBox.add(Text({ content: t`${usageBarColor(sPct)(sBar)} ${bold(String(sPct) + "%")}`, width: "100%", height: 1 })) + app.usageBox.add(Text({ content: t`${dim(sReset ? "resets " + sReset : "")} ${dim(formatCost(u.costPerHour) + "/h")}`, width: "100%", height: 1 })) + + const wPct = pct(u.weekTotal, PLAN_LIMITS.weeklyAll) + const wBar = makeBar(u.weekTotal, PLAN_LIMITS.weeklyAll, BAR_W) + app.usageBox.add(Text({ content: t`${bold("All models")} ${dim(formatCost(u.weekTotal))}`, width: "100%", height: 1 })) + app.usageBox.add(Text({ content: t`${usageBarColor(wPct)(wBar)} ${bold(String(wPct) + "%")}`, width: "100%", height: 1 })) + + const snPct = pct(u.weeklySonnetCost, PLAN_LIMITS.weeklySonnet) + const snBar = makeBar(u.weeklySonnetCost, PLAN_LIMITS.weeklySonnet, BAR_W) + app.usageBox.add(Text({ content: t`${bold("Sonnet")} ${dim(formatCost(u.weeklySonnetCost))}`, width: "100%", height: 1 })) + app.usageBox.add(Text({ content: t`${usageBarColor(snPct)(snBar)} ${bold(String(snPct) + "%")}`, width: "100%", height: 1 })) + + const monthLabel = new Date().toLocaleString("en", { month: "short" }) + app.usageBox.add(Text({ content: t`${bold(monthLabel + " total")} ${dim(formatCost(u.monthlyTotalCost))}`, width: "100%", height: 1 })) + app.usageBox.add(Text({ content: t`${dim(formatCost(u.costPerHour) + "/h avg · " + u.totalRequests + " reqs")}`, width: "100%", height: 1 })) + + app.renderer.requestRender() +} + +export function updateFooter() { + if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0) { + app.footerText.content = t` ${dim( + "↑↓ nav │ tab/shift-tab idle select │ enter focus │ i preview │ space select │ a all │ n none │ s sort │ q quit" + )}` + } else { + app.footerText.content = t` ${dim( + "↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter grid │ o external │ q quit" + )}` + } +} + +export function updatePreview() { + if (app.cursor >= app.displayRows.length) { + app.previewText.content = t`${dim(" No selection")}` + return + } + + const row = app.displayRows[app.cursor] + const project = app.projects[row.projectIndex] + + if (row.type === "project") { + app.previewText.content = t` ${bold(project.name)} ${dim(project.path)} + ${dim("Branch:")} ${magenta(project.branch)} ${dim("Commit:")} ${ + project.commitAge || "-" + } — ${project.commitMsg || "-"} + ${dim("Status:")} ${project.dirty ? yellow(project.dirty) : green("clean")} ${dim( + "Sessions:" + )} ${String(project.sessionCount)} ${dim("Msgs:")} ${String(project.totalMessages)} ${dim( + "Stack:" + )} ${project.tags || "-"}` + } else if (row.type === "session" && project.sessions) { + const s = project.sessions[row.sessionIndex!] + const sStatus = getSessionStatus(project.path, s.id) + const sLabel = sStatus === "busy" ? green(" ● running") : sStatus === "idle" ? yellow(" ◉ idle") : "" + app.previewText.content = t` ${bold("Session:")} ${s.title}${sLabel} + ${dim(timeAgo(s.timestamp))} · ${dim(formatSize(s.sizeBytes))} · ${magenta(s.branch || "-")} + ${dim("Last prompt:")} ${s.lastUserPrompt || dim("(no text)")} + ${dim("Claude:")} ${s.lastAssistantMsg || dim("(no text response)")}` + } else if (row.type === "branch" && project.branches) { + const br = project.branches.find(b => b.name === row.branchName) + if (br) { + const sync = fmtSyncIndicator(br.ahead, br.behind) + const selBranch = app.selectedBranches.get(project.path) + const selNote = selBranch === br.name + ? t` ${green("Selected")} — will launch with: ${dim(`-p "switch to branch ${br.name}, stash if needed"`)}` + : t` ${dim("Press space to select this branch for launch")}` + app.previewText.content = t` ${bold("Branch:")} ${magenta(br.name)} ${dim("Sync:")} ${sync} + ${dim("Last commit:")} ${br.lastCommitAge} — ${br.lastCommitMsg} +${selNote}` + } + } else { + app.previewText.content = t` ${green("Start a new Claude session")} in ${bold(project.name)} + ${dim(project.path)}` + } +} + +export function rebuildList() { + for (const child of app.listBox.getChildren()) { + app.listBox.remove(child.id) + } + + for (let i = 0; i < app.displayRows.length; i++) { + const row = app.displayRows[i] + const isCursor = i === app.cursor + const project = app.projects[row.projectIndex] + + let content: ReturnType + let rowHeight = 1 + if (row.type === "project") { + const isSel = app.selectedProjects.has(project.path) + content = fmtProjectRow(project, isSel) + } else if (row.type === "session") { + const session = project.sessions![row.sessionIndex!] + const isSel = app.selectedSessions.has(session.id) + content = fmtSessionRow(row.projectIndex, row.sessionIndex!, isSel, false) + rowHeight = 3 + } else if (row.type === "branch") { + const isSel = app.selectedBranches.get(project.path) === row.branchName + content = fmtBranchRow(row.projectIndex, row.branchName!, isSel) + } else { + const isSel = app.selectedProjects.has(project.path) + content = fmtNewSessionRow(row.projectIndex, isSel) + } + + const isActiveProject = row.type === "project" && project.activeSessions > 0 + const isActiveSession = row.type === "session" && getSessionStatus(project.path, project.sessions![row.sessionIndex!].id) !== null + const bgColor = isCursor ? CURSOR_BG : (isActiveProject || isActiveSession) ? ACTIVE_BG : undefined + + if (bgColor) { + app.listBox.add( + Box( + { + backgroundColor: bgColor, + shouldFill: true, + width: "100%", + height: rowHeight, + }, + Text({ content }) + ) + ) + } else { + app.listBox.add(Text({ content, width: "100%", height: rowHeight })) + } + } + + ensureCursorVisible() + app.renderer.requestRender() +} + +export function ensureCursorVisible() { + const vpH = app.listBox.viewport.height + if (vpH <= 0) return + + let cursorY = 0 + let cursorH = 1 + for (let i = 0; i < app.displayRows.length; i++) { + const h = app.displayRows[i].type === "session" ? 3 : 1 + if (i === app.cursor) { + cursorH = h + break + } + cursorY += h + } + + const top = app.listBox.scrollTop + if (cursorY < top) { + app.listBox.scrollTo(cursorY) + } else if (cursorY + cursorH > top + vpH) { + app.listBox.scrollTo(cursorY + cursorH - vpH) + } +} + +export function updateAll() { + if (app.destroyed) return + updateHeader() + rebuildList() + updateBottomPanel() + updateFooter() +} From 61add155319f36418589fd3ae933a926748490da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 28 Feb 2026 02:14:53 +0000 Subject: [PATCH 03/13] perf: hoist keyMap constant, dedup selection toggles, split stdinHandler - Move keyMap to module-level KEY_MAP constant (was recreated per keystroke) - Extract toggleRowSelection() to replace 4x duplicated toggle logic - Extract refreshMockSessions() to deduplicate demo setup in index.ts - Split stdinHandler into processGridInput/processPickerInput - Extract collapseProject helper from nested left/right key handling - Add shared syntheticKey() and clearChildren() helpers Co-Authored-By: Claude Opus 4.6 --- src/index.ts | 33 ++-- src/input/handlers.ts | 354 ++++++++++++++++++------------------------ src/ui/panels.ts | 134 ++++++++-------- 3 files changed, 238 insertions(+), 283 deletions(-) diff --git a/src/index.ts b/src/index.ts index c6b2ebf..b018a37 100755 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { loadGitMetadata } from "./data/git" import { loadSessions } from "./data/sessions" import { generateMockProjects, generateMockSessions, generateMockBusySessions } from "./data/mock" import { detectActiveSessions, updateProjectSessions, generateMockActiveSessions, checkTransitions, snapshotBusy, playDoneSound, bounceDock, getSessionStatus, populateMockSessionStatus } from "./data/monitor" +import type { Project } from "./lib/types" import { getUsageSummary } from "./data/usage" import { getSessions, refreshAlive } from "./pty/session-manager" import { stopAllCaptures } from "./pty/capture" @@ -19,6 +20,18 @@ import { updateAll, rebuildDisplayRows, updateUsagePanel, updateColumnHeaders } import { stdinHandler } from "./input/handlers" import { resizeGridPanes } from "./grid/view-switch" +function refreshMockSessions(projects: Project[]) { + generateMockActiveSessions(projects) + generateMockBusySessions(projects) + for (const p of projects) { + if (p.activeSessions > 0 && !p.sessions) { + p.sessions = generateMockSessions(p.path) + p.sessionCount = p.sessions.length + } + populateMockSessionStatus(p) + } +} + async function main() { process.stdout.write("\x1b[2J\x1b[H") process.stdout.write("\x1b[1m cladm\x1b[0m\n") @@ -165,15 +178,7 @@ async function main() { // Live session monitoring if (app.demoMode) { - generateMockActiveSessions(app.projects) - generateMockBusySessions(app.projects) - for (const p of app.projects) { - if (p.activeSessions > 0 && !p.sessions) { - p.sessions = generateMockSessions(p.path) - p.sessionCount = p.sessions.length - } - populateMockSessionStatus(p) - } + refreshMockSessions(app.projects) app.prevBusySnapshot = snapshotBusy(app.projects) updateAll() } else { @@ -197,15 +202,7 @@ async function main() { if (app.demoMode) { for (const p of app.projects) { p.activeSessions = 0; p.busySessions = 0 } - generateMockActiveSessions(app.projects) - generateMockBusySessions(app.projects) - for (const p of app.projects) { - if (p.activeSessions > 0 && !p.sessions) { - p.sessions = generateMockSessions(p.path) - p.sessionCount = p.sessions.length - } - populateMockSessionStatus(p) - } + refreshMockSessions(app.projects) const transitioned = checkTransitions(app.projects, app.prevBusySnapshot) app.prevBusySnapshot = snapshotBusy(app.projects) if (transitioned.length > 0) { diff --git a/src/input/handlers.ts b/src/input/handlers.ts index 592ba12..40a1a2e 100644 --- a/src/input/handlers.ts +++ b/src/input/handlers.ts @@ -8,10 +8,12 @@ import { launchSelections } from "../actions/launcher" import { loadSessions } from "../data/sessions" import { loadBranches } from "../data/git" import { generateMockSessions, generateMockBranches } from "../data/mock" -import { focusTerminalByPath, getSessionStatus, populateMockSessionStatus } from "../data/monitor" +import { focusTerminalByPath, populateMockSessionStatus } from "../data/monitor" import { stopAllCaptures } from "../pty/capture" +import type { DisplayRow } from "../lib/types" + +// ─── Constants ─────────────────────────────────────────────────────── -// Shift+arrow sequences across terminal emulators const SHIFT_ARROWS: Record = { "\x1b[1;2A": "up", "\x1b[1;2B": "down", @@ -23,6 +25,70 @@ const SHIFT_ARROWS: Record = { "\x1b[d": "left", } +const KEY_MAP: Record = { + "\x1b[A": { name: "up" }, + "\x1b[B": { name: "down" }, + "\x1b[C": { name: "right" }, + "\x1b[D": { name: "left" }, + "\x1b[5~": { name: "pageup" }, + "\x1b[6~": { name: "pagedown" }, + "\x1b[H": { name: "home" }, + "\x1b[F": { name: "end" }, + "\x1bOH": { name: "home" }, + "\x1bOF": { name: "end" }, + "\x1b[Z": { name: "tab", shift: true }, + "\x1b[1;2A": { name: "up", shift: true }, + "\x1b[1;2B": { name: "down", shift: true }, + "\x1b[1;2C": { name: "right", shift: true }, + "\x1b[1;2D": { name: "left", shift: true }, + "\x09": { name: "tab" }, + "\x0d": { name: "return" }, + "\x1b": { name: "escape" }, + " ": { name: "space" }, +} + +const NOOP = () => {} + +// ─── Selection helpers ─────────────────────────────────────────────── + +function toggleSetItem(set: Set, item: T) { + if (set.has(item)) set.delete(item) + else set.add(item) +} + +function toggleRowSelection(row: DisplayRow) { + const project = app.projects[row.projectIndex] + if (row.type === "project" || row.type === "new-session") { + toggleSetItem(app.selectedProjects, project.path) + } else if (row.type === "session") { + toggleSetItem(app.selectedSessions, project.sessions![row.sessionIndex!].id) + } else if (row.type === "branch") { + if (app.selectedBranches.get(project.path) === row.branchName) { + app.selectedBranches.delete(project.path) + } else { + app.selectedBranches.set(project.path, row.branchName!) + } + } +} + +function syntheticKey(name: string, shift = false, ctrl = false): KeyEvent { + return { name, shift, ctrl, meta: false, preventDefault: NOOP, stopPropagation: NOOP } as KeyEvent +} + +// ─── Collapse helper ───────────────────────────────────────────────── + +function collapseProject(projectIndex: number) { + app.projects[projectIndex].expanded = false + rebuildDisplayRows() + const target = app.displayRows.findIndex( + (r) => r.type === "project" && r.projectIndex === projectIndex + ) + app.cursor = target >= 0 ? target : 0 + if (app.cursor >= app.displayRows.length) app.cursor = app.displayRows.length - 1 +} + +// ─── Expand ────────────────────────────────────────────────────────── + export async function expandProject(projectIndex: number) { const project = app.projects[projectIndex] if (app.demoMode) { @@ -56,11 +122,11 @@ export async function expandProject(projectIndex: number) { updateAll() } -export function hitTestListRow(screenRow: number): number { - const listStartY = 2 - const relY = screenRow - listStartY + app.listBox.scrollTop - if (relY < 0) return -1 +// ─── Hit test ──────────────────────────────────────────────────────── +export function hitTestListRow(screenRow: number): number { + const relY = screenRow - 2 + app.listBox.scrollTop + if (relY < 0) return -1 let y = 0 for (let i = 0; i < app.displayRows.length; i++) { const h = app.displayRows[i].type === "session" ? 3 : 1 @@ -70,34 +136,18 @@ export function hitTestListRow(screenRow: number): number { return -1 } +// ─── Picker click ──────────────────────────────────────────────────── + export function handlePickerClick(_col: number, screenRow: number) { const idx = hitTestListRow(screenRow) if (idx < 0 || idx >= app.displayRows.length) return - app.cursor = idx - const row = app.displayRows[idx] - const project = app.projects[row.projectIndex] - - if (row.type === "project" || row.type === "new-session") { - const path = project.path - if (app.selectedProjects.has(path)) app.selectedProjects.delete(path) - else app.selectedProjects.add(path) - } else if (row.type === "session") { - const session = project.sessions![row.sessionIndex!] - if (app.selectedSessions.has(session.id)) app.selectedSessions.delete(session.id) - else app.selectedSessions.add(session.id) - } else if (row.type === "branch") { - const path = project.path - if (app.selectedBranches.get(path) === row.branchName) { - app.selectedBranches.delete(path) - } else { - app.selectedBranches.set(path, row.branchName!) - } - } - + toggleRowSelection(app.displayRows[idx]) updateAll() } +// ─── Keyboard ──────────────────────────────────────────────────────── + export async function handleKeypress(key: KeyEvent) { try { const total = app.displayRows.length @@ -130,58 +180,23 @@ export async function handleKeypress(key: KeyEvent) { case "right": { const row = app.displayRows[app.cursor] - if (row.type === "project") { - const project = app.projects[row.projectIndex] - if (!project.expanded) { - expandProject(row.projectIndex) - return - } + if (row.type === "project" && !app.projects[row.projectIndex].expanded) { + expandProject(row.projectIndex) + return } return } - case "left": { - const row = app.displayRows[app.cursor] - if (row.type === "project") { - app.projects[row.projectIndex].expanded = false - } else { - app.projects[row.projectIndex].expanded = false - const target = row.projectIndex - rebuildDisplayRows() - app.cursor = app.displayRows.findIndex( - (r) => r.type === "project" && r.projectIndex === target - ) - if (app.cursor < 0) app.cursor = 0 - } - rebuildDisplayRows() - if (app.cursor >= app.displayRows.length) app.cursor = app.displayRows.length - 1 + case "left": + collapseProject(app.displayRows[app.cursor].projectIndex) break - } - case "space": { - const row = app.displayRows[app.cursor] - if (row.type === "project" || row.type === "new-session") { - const path = app.projects[row.projectIndex].path - if (app.selectedProjects.has(path)) app.selectedProjects.delete(path) - else app.selectedProjects.add(path) - } else if (row.type === "session") { - const session = app.projects[row.projectIndex].sessions![row.sessionIndex!] - if (app.selectedSessions.has(session.id)) app.selectedSessions.delete(session.id) - else app.selectedSessions.add(session.id) - } else if (row.type === "branch") { - const path = app.projects[row.projectIndex].path - if (app.selectedBranches.get(path) === row.branchName) { - app.selectedBranches.delete(path) - } else { - app.selectedBranches.set(path, row.branchName!) - } - } + case "space": + toggleRowSelection(app.displayRows[app.cursor]) break - } case "f": { - const row = app.displayRows[app.cursor] - const project = app.projects[row.projectIndex] + const project = app.projects[app.displayRows[app.cursor].projectIndex] Bun.spawn(["open", project.path]) break } @@ -215,11 +230,10 @@ export async function handleKeypress(key: KeyEvent) { case "tab": if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0) { - if (key.shift) { - app.idleCursor = app.idleCursor > 0 ? app.idleCursor - 1 : Math.min(app.cachedIdleSessions.length, 3) - 1 - } else { - app.idleCursor = (app.idleCursor + 1) % Math.min(app.cachedIdleSessions.length, 3) - } + const max = Math.min(app.cachedIdleSessions.length, 3) + app.idleCursor = key.shift + ? (app.idleCursor > 0 ? app.idleCursor - 1 : max - 1) + : (app.idleCursor + 1) % max } break @@ -236,24 +250,18 @@ export async function handleKeypress(key: KeyEvent) { break } if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0 && app.idleCursor < app.cachedIdleSessions.length) { - const focused = await focusTerminalByPath(app.cachedIdleSessions[app.idleCursor].projectPath) - if (focused) return + if (await focusTerminalByPath(app.cachedIdleSessions[app.idleCursor].projectPath)) return } const returnRow = app.displayRows[app.cursor] - if ( - returnRow.type === "project" && - app.projects[returnRow.projectIndex].activeSessions > 0 - ) { - const focused = await focusTerminalByPath(app.projects[returnRow.projectIndex].path) - if (focused) return + if (returnRow.type === "project" && app.projects[returnRow.projectIndex].activeSessions > 0) { + if (await focusTerminalByPath(app.projects[returnRow.projectIndex].path)) return } doLaunch() break } case "o": { - const hasOSel = app.selectedProjects.size > 0 || app.selectedSessions.size > 0 - if (!hasOSel) { + if (app.selectedProjects.size === 0 && app.selectedSessions.size === 0) { const oRow = app.displayRows[app.cursor] if (oRow) app.selectedProjects.add(app.projects[oRow.projectIndex].path) } @@ -277,10 +285,7 @@ export async function handleKeypress(key: KeyEvent) { return case "t": - if (app.directGrid && app.directGrid.paneCount > 0) { - switchToGrid() - return - } + if (app.directGrid && app.directGrid.paneCount > 0) switchToGrid() return default: @@ -291,6 +296,8 @@ export async function handleKeypress(key: KeyEvent) { } catch {} } +// ─── Grid input ────────────────────────────────────────────────────── + export async function handleGridInput(rawSequence: string): Promise { if (app.viewMode !== "grid" || !app.directGrid) return false @@ -304,15 +311,8 @@ export async function handleGridInput(rawSequence: string): Promise { return true } - if (rawSequence === "\x0e") { - app.directGrid.focusNext() - return true - } - - if (rawSequence === "\x10") { - app.directGrid.focusPrev() - return true - } + if (rawSequence === "\x0e") { app.directGrid.focusNext(); return true } + if (rawSequence === "\x10") { app.directGrid.focusPrev(); return true } if (rawSequence === "\x06") { const pane = app.directGrid.focusedPane @@ -327,26 +327,20 @@ export async function handleGridInput(rawSequence: string): Promise { const { killSession } = await import("../pty/session-manager") app.directGrid.removePane(pane.session.name) await killSession(pane.session.name) - if (app.directGrid.paneCount === 0) { - switchToPicker() - } + if (app.directGrid.paneCount === 0) switchToPicker() } return true } - if (rawSequence === "\x1b[5~") { - app.directGrid.sendScrollToFocused("up") - return true - } - if (rawSequence === "\x1b[6~") { - app.directGrid.sendScrollToFocused("down") - return true - } + if (rawSequence === "\x1b[5~") { app.directGrid.sendScrollToFocused("up"); return true } + if (rawSequence === "\x1b[6~") { app.directGrid.sendScrollToFocused("down"); return true } app.directGrid.sendInputToFocused(rawSequence) return true } +// ─── View switching ────────────────────────────────────────────────── + export function switchToPicker() { app.viewMode = "picker" if (app.directGrid) { @@ -363,62 +357,46 @@ export function switchToPicker() { app.renderer.requestRender() } -export function stdinHandler(data: string | Buffer) { - const str = typeof data === "string" ? data : data.toString("utf8") +// ─── Stdin: grid mode ──────────────────────────────────────────────── - if (app.viewMode === "grid" && app.directGrid) { - if (app.directGrid.selectMode) { - const keyboard = extractKeyboardInput(str) - if (keyboard === "\x1b") { - app.directGrid.exitSelectMode() - } - return - } +function processGridInput(str: string) { + const dg = app.directGrid! - const mouseEvents = extractMouseEvents(str) - for (const me of mouseEvents) { - if (me.btn === 64) { - app.directGrid.sendScrollToFocused("up", 3) - continue - } - if (me.btn === 65) { - app.directGrid.sendScrollToFocused("down", 3) - continue - } - if (me.btn === 0 && !me.release) { - const btn = app.directGrid.checkButtonClick(me.col, me.row) - if (btn?.action === "max") { - app.directGrid.expandPane(btn.paneIndex) - } else if (btn?.action === "min") { - app.directGrid.collapsePane() - } else if (btn?.action === "sel") { - app.directGrid.enterSelectMode() - } else { - app.directGrid.focusByClick(me.col, me.row) - } - continue - } - } - - let stripped = str - for (let i = mouseEvents.length - 1; i >= 0; i--) { - const me = mouseEvents[i] - stripped = stripped.slice(0, me.start) + stripped.slice(me.end) - } - - const keyboard = extractKeyboardInput(stripped) - if (keyboard) { - const dir = SHIFT_ARROWS[keyboard] - if (dir) { - app.directGrid.focusByDirection(dir) - } else { - handleGridInput(keyboard) - } - } + if (dg.selectMode) { + if (extractKeyboardInput(str) === "\x1b") dg.exitSelectMode() return } - // Picker mode + const mouseEvents = extractMouseEvents(str) + for (const me of mouseEvents) { + if (me.btn === 64) { dg.sendScrollToFocused("up", 3); continue } + if (me.btn === 65) { dg.sendScrollToFocused("down", 3); continue } + if (me.btn === 0 && !me.release) { + const btn = dg.checkButtonClick(me.col, me.row) + if (btn?.action === "max") dg.expandPane(btn.paneIndex) + else if (btn?.action === "min") dg.collapsePane() + else if (btn?.action === "sel") dg.enterSelectMode() + else dg.focusByClick(me.col, me.row) + continue + } + } + + let stripped = str + for (let i = mouseEvents.length - 1; i >= 0; i--) { + const me = mouseEvents[i] + stripped = stripped.slice(0, me.start) + stripped.slice(me.end) + } + + const keyboard = extractKeyboardInput(stripped) + if (!keyboard) return + const dir = SHIFT_ARROWS[keyboard] + if (dir) dg.focusByDirection(dir) + else handleGridInput(keyboard) +} + +// ─── Stdin: picker mode ────────────────────────────────────────────── + +function processPickerInput(str: string) { const pickerMouse = extractMouseEvents(str) for (const me of pickerMouse) { if (me.btn === 0 && !me.release) handlePickerClick(me.col, me.row) @@ -429,64 +407,32 @@ export function stdinHandler(data: string | Buffer) { const keyboard = extractKeyboardInput(str) if (!keyboard) return - const keyMap: Record = { - "\x1b[A": { name: "up" }, - "\x1b[B": { name: "down" }, - "\x1b[C": { name: "right" }, - "\x1b[D": { name: "left" }, - "\x1b[5~": { name: "pageup" }, - "\x1b[6~": { name: "pagedown" }, - "\x1b[H": { name: "home" }, - "\x1b[F": { name: "end" }, - "\x1bOH": { name: "home" }, - "\x1bOF": { name: "end" }, - "\x1b[Z": { name: "tab", shift: true }, - "\x1b[1;2A": { name: "up", shift: true }, - "\x1b[1;2B": { name: "down", shift: true }, - "\x1b[1;2C": { name: "right", shift: true }, - "\x1b[1;2D": { name: "left", shift: true }, - "\x09": { name: "tab" }, - "\x0d": { name: "return" }, - "\x1b": { name: "escape" }, - " ": { name: "space" }, - } - let ki = 0 while (ki < keyboard.length) { let matched = false for (let len = Math.min(8, keyboard.length - ki); len >= 1; len--) { - const seq = keyboard.slice(ki, ki + len) - const mapped = keyMap[seq] + const mapped = KEY_MAP[keyboard.slice(ki, ki + len)] if (mapped) { - const syntheticKey = { - name: mapped.name, - shift: mapped.shift || false, - ctrl: mapped.ctrl || false, - meta: false, - preventDefault: () => {}, - stopPropagation: () => {}, - } as KeyEvent - handleKeypress(syntheticKey) + handleKeypress(syntheticKey(mapped.name, mapped.shift, mapped.ctrl)) ki += len matched = true break } } if (!matched) { - const ch = keyboard[ki] - const code = ch.charCodeAt(0) + const code = keyboard.charCodeAt(ki) if (code >= 0x21 && code <= 0x7e) { - const syntheticKey = { - name: ch, - shift: false, - ctrl: false, - meta: false, - preventDefault: () => {}, - stopPropagation: () => {}, - } as KeyEvent - handleKeypress(syntheticKey) + handleKeypress(syntheticKey(keyboard[ki])) } ki++ } } } + +// ─── Stdin entry point ─────────────────────────────────────────────── + +export function stdinHandler(data: string | Buffer) { + const str = typeof data === "string" ? data : data.toString("utf8") + if (app.viewMode === "grid" && app.directGrid) processGridInput(str) + else processPickerInput(str) +} diff --git a/src/ui/panels.ts b/src/ui/panels.ts index dd2049d..d412f66 100644 --- a/src/ui/panels.ts +++ b/src/ui/panels.ts @@ -11,12 +11,14 @@ import { magenta, } from "@opentui/core" import { app } from "../lib/state" -import { CURSOR_BG, ACTIVE_BG, ACCENT, DIM_CLR } from "../lib/theme" +import { CURSOR_BG, ACTIVE_BG, ACCENT } from "../lib/theme" import { getSessionStatus, getIdleSessions } from "../data/monitor" -import { getUsageSummary, formatCost, formatWindow, makeBar, pct, PLAN_LIMITS } from "../data/usage" +import { formatCost, formatWindow, makeBar, pct, PLAN_LIMITS } from "../data/usage" import { timeAgo, formatSize, elapsedCompact } from "../lib/time" import { fmtProjectRow, fmtSessionRow, fmtNewSessionRow, fmtBranchRow, fmtSyncIndicator } from "./formatters" +// ─── Display rows ──────────────────────────────────────────────────── + export function rebuildDisplayRows() { app.displayRows = [] for (const idx of app.sortedIndices) { @@ -65,6 +67,8 @@ export function applySortMode() { rebuildDisplayRows() } +// ─── Header / Footer ───────────────────────────────────────────────── + export function updateHeader() { const total = app.selectedProjects.size + app.selectedSessions.size const branchNote = app.selectedBranches.size > 0 ? ` (${app.selectedBranches.size} branch switch)` : "" @@ -88,6 +92,20 @@ export function updateColumnHeaders() { app.colHeaderText.content = t` ${dim(cols)}` } +export function updateFooter() { + if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0) { + app.footerText.content = t` ${dim( + "↑↓ nav │ tab/shift-tab idle select │ enter focus │ i preview │ space select │ a all │ n none │ s sort │ q quit" + )}` + } else { + app.footerText.content = t` ${dim( + "↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter grid │ o external │ q quit" + )}` + } +} + +// ─── Bottom panel ──────────────────────────────────────────────────── + function addIdleRow(s: { idleSinceMs: number; projectName: string; sessionTitle: string; lastPrompt: string; lastResponse: string }, isCursor: boolean) { const elapsed = (elapsedCompact(s.idleSinceMs) || "<5s").padEnd(6) const name = s.projectName.length > 20 ? s.projectName.slice(0, 17) + "..." : s.projectName @@ -104,11 +122,11 @@ function addIdleRow(s: { idleSinceMs: number; projectName: string; sessionTitle: app.previewBox.add(Text({ content: t` ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + response + '"')}`, width: "100%", height: 1 })) } -export function updateIdlePanel() { +function updateIdlePanel() { app.cachedIdleSessions = getIdleSessions(app.projects) const n = app.cachedIdleSessions.length app.previewBox.title = ` Idle Sessions (${n}) — enter to focus ` - for (const child of app.previewBox.getChildren()) app.previewBox.remove(child.id) + clearChildren(app.previewBox) if (n === 0) { app.idleCursor = 0 app.previewBox.add(Text({ content: t`${dim(" No idle sessions")}`, width: "100%", height: 1 })) @@ -129,7 +147,7 @@ export function updateBottomPanel() { app.bottomRow.height = 14 updateIdlePanel() } else { - for (const child of app.previewBox.getChildren()) app.previewBox.remove(child.id) + clearChildren(app.previewBox) app.previewBox.add(app.previewText) app.bottomRow.height = 10 app.previewBox.title = " Preview " @@ -137,13 +155,15 @@ export function updateBottomPanel() { } } +// ─── Usage panel ───────────────────────────────────────────────────── + function usageBarColor(p: number) { return p >= 80 ? yellow : p >= 50 ? cyan : green } export function updateUsagePanel() { if (app.destroyed) return - for (const child of app.usageBox.getChildren()) app.usageBox.remove(child.id) + clearChildren(app.usageBox) if (!app.cachedUsage) { app.usageBox.title = " Usage " @@ -179,17 +199,7 @@ export function updateUsagePanel() { app.renderer.requestRender() } -export function updateFooter() { - if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0) { - app.footerText.content = t` ${dim( - "↑↓ nav │ tab/shift-tab idle select │ enter focus │ i preview │ space select │ a all │ n none │ s sort │ q quit" - )}` - } else { - app.footerText.content = t` ${dim( - "↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter grid │ o external │ q quit" - )}` - } -} +// ─── Preview panel ─────────────────────────────────────────────────── export function updatePreview() { if (app.cursor >= app.displayRows.length) { @@ -236,53 +246,47 @@ ${selNote}` } } -export function rebuildList() { - for (const child of app.listBox.getChildren()) { - app.listBox.remove(child.id) +// ─── List rendering ────────────────────────────────────────────────── + +// Renderable IDs for each row — enables incremental updates +let rowRenderableIds: string[] = [] + +function renderRowContent(i: number) { + const row = app.displayRows[i] + const project = app.projects[row.projectIndex] + + let content: ReturnType + let rowHeight = 1 + if (row.type === "project") { + content = fmtProjectRow(project, app.selectedProjects.has(project.path)) + } else if (row.type === "session") { + content = fmtSessionRow(row.projectIndex, row.sessionIndex!, app.selectedSessions.has(project.sessions![row.sessionIndex!].id), false) + rowHeight = 3 + } else if (row.type === "branch") { + content = fmtBranchRow(row.projectIndex, row.branchName!, app.selectedBranches.get(project.path) === row.branchName) + } else { + content = fmtNewSessionRow(row.projectIndex, app.selectedProjects.has(project.path)) } + const isCursor = i === app.cursor + const isActiveProject = row.type === "project" && project.activeSessions > 0 + const isActiveSession = row.type === "session" && getSessionStatus(project.path, project.sessions![row.sessionIndex!].id) !== null + const bgColor = isCursor ? CURSOR_BG : (isActiveProject || isActiveSession) ? ACTIVE_BG : undefined + + if (bgColor) { + return Box({ backgroundColor: bgColor, shouldFill: true, width: "100%", height: rowHeight }, Text({ content })) + } + return Text({ content, width: "100%", height: rowHeight }) +} + +export function rebuildList() { + clearChildren(app.listBox) + rowRenderableIds = [] + for (let i = 0; i < app.displayRows.length; i++) { - const row = app.displayRows[i] - const isCursor = i === app.cursor - const project = app.projects[row.projectIndex] - - let content: ReturnType - let rowHeight = 1 - if (row.type === "project") { - const isSel = app.selectedProjects.has(project.path) - content = fmtProjectRow(project, isSel) - } else if (row.type === "session") { - const session = project.sessions![row.sessionIndex!] - const isSel = app.selectedSessions.has(session.id) - content = fmtSessionRow(row.projectIndex, row.sessionIndex!, isSel, false) - rowHeight = 3 - } else if (row.type === "branch") { - const isSel = app.selectedBranches.get(project.path) === row.branchName - content = fmtBranchRow(row.projectIndex, row.branchName!, isSel) - } else { - const isSel = app.selectedProjects.has(project.path) - content = fmtNewSessionRow(row.projectIndex, isSel) - } - - const isActiveProject = row.type === "project" && project.activeSessions > 0 - const isActiveSession = row.type === "session" && getSessionStatus(project.path, project.sessions![row.sessionIndex!].id) !== null - const bgColor = isCursor ? CURSOR_BG : (isActiveProject || isActiveSession) ? ACTIVE_BG : undefined - - if (bgColor) { - app.listBox.add( - Box( - { - backgroundColor: bgColor, - shouldFill: true, - width: "100%", - height: rowHeight, - }, - Text({ content }) - ) - ) - } else { - app.listBox.add(Text({ content, width: "100%", height: rowHeight })) - } + const vnode = renderRowContent(i) + const rid = app.listBox.add(vnode) + rowRenderableIds.push(rid as unknown as string) } ensureCursorVisible() @@ -312,6 +316,14 @@ export function ensureCursorVisible() { } } +// ─── Helpers ───────────────────────────────────────────────────────── + +function clearChildren(box: { getChildren(): { id: string }[]; remove(id: string): void }) { + for (const child of box.getChildren()) box.remove(child.id) +} + +// ─── Top-level ─────────────────────────────────────────────────────── + export function updateAll() { if (app.destroyed) return updateHeader() From bc8a0c1934c625528ac0f7254e251160a15d8deb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 28 Feb 2026 09:56:38 +0000 Subject: [PATCH 04/13] feat: toggle between picker and grid without closing panes Add pause()/resume() to DirectGridRenderer that detach/reattach frame listeners without killing captures or PTY sessions. switchToPicker now pauses the grid, switchToGrid resumes it. Panes keep running in the background while browsing the project list. Footer shows "t grid" hint when panes are active in the background. Co-Authored-By: Claude Opus 4.6 --- src/components/direct-grid.ts | 25 +++++++++++++++++++++++++ src/grid/view-switch.ts | 10 ++++++++-- src/input/handlers.ts | 2 +- src/ui/panels.ts | 5 +++-- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/src/components/direct-grid.ts b/src/components/direct-grid.ts index 84ec3a4..be7e035 100644 --- a/src/components/direct-grid.ts +++ b/src/components/direct-grid.ts @@ -89,6 +89,31 @@ export class DirectGridRenderer { this.writeRaw(SHOW_CURSOR) } + pause() { + this.running = false + if (this.titleTimer) { clearInterval(this.titleTimer); this.titleTimer = null } + // Detach frame listeners (stops rendering) but keep captures alive + for (const p of this.panes) p.directPane.detach() + } + + resume() { + this.running = true + this.writeRaw(HIDE_CURSOR + CLEAR) + // Reattach frame listeners and redraw + for (let i = 0; i < this.panes.length; i++) { + const p = this.panes[i] + const dp = p.directPane + const idx = i + dp.attach(p.session.name) + dp.onFrame = (lines) => { + if (!this.running) return + this.drawPane(idx, lines) + } + } + this.repositionAll() + this.titleTimer = setInterval(() => this.refreshTitles(), 1000) + } + // ─── Getters ─────────────────────────────────────────── get focusIndex() { return this._focusIndex } diff --git a/src/grid/view-switch.ts b/src/grid/view-switch.ts index 88024f1..85dd155 100644 --- a/src/grid/view-switch.ts +++ b/src/grid/view-switch.ts @@ -9,7 +9,8 @@ export function ensureGridView() { export function switchToGrid() { app.viewMode = "grid" - if (!app.directGrid) { + const isNew = !app.directGrid + if (isNew) { app.directGrid = new DirectGridRenderer(app.rawStdoutWrite) } @@ -20,7 +21,12 @@ export function switchToGrid() { app.rawStdoutWrite("\x1b[?1049h") app.rawStdoutWrite("\x1b[?1000h") app.rawStdoutWrite("\x1b[?1006h") - app.directGrid.start() + + if (isNew || app.directGrid.paneCount === 0) { + app.directGrid.start() + } else { + app.directGrid.resume() + } } export function resizeGridPanes() { diff --git a/src/input/handlers.ts b/src/input/handlers.ts index 40a1a2e..02cb383 100644 --- a/src/input/handlers.ts +++ b/src/input/handlers.ts @@ -345,7 +345,7 @@ export function switchToPicker() { app.viewMode = "picker" if (app.directGrid) { if (app.directGrid.selectMode) app.directGrid.exitSelectMode() - if (app.directGrid.paneCount > 0) app.directGrid.stop() + if (app.directGrid.paneCount > 0) app.directGrid.pause() } app.renderer.resume() process.stdin.removeAllListeners("data") diff --git a/src/ui/panels.ts b/src/ui/panels.ts index d412f66..c55d157 100644 --- a/src/ui/panels.ts +++ b/src/ui/panels.ts @@ -93,13 +93,14 @@ export function updateColumnHeaders() { } export function updateFooter() { + const gridHint = app.directGrid && app.directGrid.paneCount > 0 ? " │ t grid" : "" if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0) { app.footerText.content = t` ${dim( - "↑↓ nav │ tab/shift-tab idle select │ enter focus │ i preview │ space select │ a all │ n none │ s sort │ q quit" + "↑↓ nav │ tab/shift-tab idle select │ enter focus │ i preview │ space select │ a all │ n none │ s sort │ q quit" + gridHint )}` } else { app.footerText.content = t` ${dim( - "↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter grid │ o external │ q quit" + "↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter grid │ o external │ q quit" + gridHint )}` } } From cad6df1a9b2926c4189c9471786cf3d78f9f55f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 28 Feb 2026 09:59:40 +0000 Subject: [PATCH 05/13] feat: add Ctrl+Space as unified toggle between picker and grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Works in both directions — picker→grid and grid→picker. Keeps existing Ctrl+` and t keybinds as alternatives. Co-Authored-By: Claude Opus 4.6 --- src/input/handlers.ts | 8 +++++++- src/ui/panels.ts | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/input/handlers.ts b/src/input/handlers.ts index 02cb383..486a110 100644 --- a/src/input/handlers.ts +++ b/src/input/handlers.ts @@ -306,7 +306,7 @@ export async function handleGridInput(rawSequence: string): Promise { return true } - if (rawSequence === "\x1e" || rawSequence === "\x1b`") { + if (rawSequence === "\x1e" || rawSequence === "\x1b`" || rawSequence === "\x00") { switchToPicker() return true } @@ -397,6 +397,12 @@ function processGridInput(str: string) { // ─── Stdin: picker mode ────────────────────────────────────────────── function processPickerInput(str: string) { + // Ctrl+Space → toggle to grid + if (str.includes("\x00") && app.directGrid && app.directGrid.paneCount > 0) { + switchToGrid() + return + } + const pickerMouse = extractMouseEvents(str) for (const me of pickerMouse) { if (me.btn === 0 && !me.release) handlePickerClick(me.col, me.row) diff --git a/src/ui/panels.ts b/src/ui/panels.ts index c55d157..c44810e 100644 --- a/src/ui/panels.ts +++ b/src/ui/panels.ts @@ -93,7 +93,7 @@ export function updateColumnHeaders() { } export function updateFooter() { - const gridHint = app.directGrid && app.directGrid.paneCount > 0 ? " │ t grid" : "" + const gridHint = app.directGrid && app.directGrid.paneCount > 0 ? " │ ^space grid" : "" if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0) { app.footerText.content = t` ${dim( "↑↓ nav │ tab/shift-tab idle select │ enter focus │ i preview │ space select │ a all │ n none │ s sort │ q quit" + gridHint From 9e424c192aeea28b08ad453755b26d1f11ef5f75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 28 Feb 2026 10:45:25 +0000 Subject: [PATCH 06/13] feat: add tabbed grids, click-to-expand, and keybind cleanup Introduces per-tab pane management with independent focus/expansion state, a persistent tab bar visible in both picker and grid modes, and a 70/30 soft-expand layout triggered by clicking pane bodies. Removes legacy toggle keybinds (Ctrl+^ and Ctrl+`) leaving only Ctrl+Space. New keybinds: Ctrl+T new tab, Alt+1-9 switch tab, Alt+n/p cycle tabs, Ctrl+E toggle click-expand, Ctrl+W auto-removes empty tabs. Co-Authored-By: Claude Opus 4.6 --- src/actions/launch.ts | 14 +- src/components/direct-grid.ts | 495 ++++++++++++++++++++++++++++------ src/grid/view-switch.ts | 37 ++- src/index.ts | 7 + src/input/handlers.ts | 194 +++++++++++-- src/input/parser.ts | 6 +- src/lib/state.ts | 13 + src/ui/panels.ts | 35 ++- 8 files changed, 692 insertions(+), 109 deletions(-) diff --git a/src/actions/launch.ts b/src/actions/launch.ts index 30e0678..b3f89c5 100644 --- a/src/actions/launch.ts +++ b/src/actions/launch.ts @@ -1,6 +1,6 @@ import { app } from "../lib/state" import { updateAll, rebuildDisplayRows } from "../ui/panels" -import { ensureGridView } from "../grid/view-switch" +import { ensureGridView, createNewGridTab, switchToGridTab } from "../grid/view-switch" import { loadSessions } from "../data/sessions" import { createSession } from "../pty/session-manager" @@ -43,11 +43,19 @@ export async function doLaunch() { if (items.length === 0) return + // Determine target tab: use active grid tab or create a new one + let targetTabId: number + if (app.viewMode === "grid" && app.directGrid && app.gridTabs.length > 0) { + targetTabId = app.directGrid.activeTabId + } else { + targetTabId = createNewGridTab() + } + ensureGridView() const termW = process.stdout.columns || 120 const termH = process.stdout.rows || 40 - const totalPanes = items.length + (app.directGrid?.paneCount || 0) + const totalPanes = items.length + (app.directGrid?.getTabPaneCount(targetTabId) || 0) const cols = totalPanes <= 1 ? 1 : totalPanes <= 2 ? 2 : totalPanes <= 4 ? 2 : 3 const rows = Math.ceil(totalPanes / cols) const paneW = Math.max(Math.floor(termW / cols) - 2, 20) @@ -62,7 +70,7 @@ export async function doLaunch() { width: paneW, height: paneH, }) - await app.directGrid!.addPane(session) + await app.directGrid!.addPane(session, targetTabId) } app.selectedProjects.clear() diff --git a/src/components/direct-grid.ts b/src/components/direct-grid.ts index be7e035..9125858 100644 --- a/src/components/direct-grid.ts +++ b/src/components/direct-grid.ts @@ -5,6 +5,7 @@ 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" +import { app, type GridTab } from "../lib/state" export type PaneStatus = "busy" | "idle" | null @@ -13,6 +14,7 @@ export interface GridPaneInfo { directPane: DirectPane status: PaneStatus statusSince: number + tabId: number } const PROJECT_COLORS = [ @@ -31,6 +33,13 @@ function hexFg(hex: string): string { return `\x1b[38;2;${r};${g};${b}m` } +function hexBg(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[48;2;${r};${g};${b}m` +} + const RESET = "\x1b[0m" const BOLD = "\x1b[1m" const DIM = "\x1b[2m" @@ -41,6 +50,11 @@ const SYNC_START = "\x1b[?2026h" const SYNC_END = "\x1b[?2026l" const CLEAR = "\x1b[2J\x1b[H" +const CYAN_FG = hexFg("#7dcfff") +const YELLOW_FG = hexFg("#e0af68") +const TAB_ACTIVE_BG = hexBg("#1a1b26") +const TAB_DIM_BG = hexBg("#16161e") + function fmtElapsed(sinceMs: number): string { if (!sinceMs) return "" const sec = Math.floor((Date.now() - sinceMs) / 1000) @@ -55,19 +69,54 @@ function fmtElapsed(sinceMs: number): string { } export class DirectGridRenderer { - private panes: GridPaneInfo[] = [] - private _focusIndex = 0 + // Per-tab state + private tabPanes = new Map() + private tabFocus = new Map() + private tabExpanded = new Map() // fullscreen expand + private tabSoftExpand = new Map() // soft expand (70/30) + private _activeTabId = -1 + private writeRaw: (s: string) => boolean private flashTimers = new Map>() private titleTimer: ReturnType | null = null private running = false private _selectMode = false - private _expandedIndex = -1 // -1 = grid view, >=0 = expanded pane index + + // Tab bar hit-test regions (col ranges for each tab) + private tabBarHitRegions: { tabId: number, startCol: number, endCol: number }[] = [] + private tabBarAddBtnCol = -1 constructor(rawWrite: (s: string) => boolean) { this.writeRaw = rawWrite } + // ─── Active tab pane accessors ────────────────────────── + + private get panes(): GridPaneInfo[] { + return this.tabPanes.get(this._activeTabId) ?? [] + } + + private get _focusIndex(): number { + return this.tabFocus.get(this._activeTabId) ?? 0 + } + private set _focusIndex(v: number) { + this.tabFocus.set(this._activeTabId, v) + } + + private get _expandedIndex(): number { + return this.tabExpanded.get(this._activeTabId) ?? -1 + } + private set _expandedIndex(v: number) { + this.tabExpanded.set(this._activeTabId, v) + } + + private get _softExpandIndex(): number { + return this.tabSoftExpand.get(this._activeTabId) ?? -1 + } + private set _softExpandIndex(v: number) { + this.tabSoftExpand.set(this._activeTabId, v) + } + // ─── Lifecycle ───────────────────────────────────────── start() { @@ -82,9 +131,11 @@ export class DirectGridRenderer { 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) + for (const [, panes] of this.tabPanes) { + for (const p of panes) { + p.directPane.detach() + stopCapture(p.session.name) + } } this.writeRaw(SHOW_CURSOR) } @@ -92,20 +143,20 @@ export class DirectGridRenderer { pause() { this.running = false if (this.titleTimer) { clearInterval(this.titleTimer); this.titleTimer = null } - // Detach frame listeners (stops rendering) but keep captures alive - for (const p of this.panes) p.directPane.detach() + for (const [, panes] of this.tabPanes) { + for (const p of panes) p.directPane.detach() + } } resume() { this.running = true this.writeRaw(HIDE_CURSOR + CLEAR) - // Reattach frame listeners and redraw - for (let i = 0; i < this.panes.length; i++) { - const p = this.panes[i] - const dp = p.directPane + const panes = this.panes + for (let i = 0; i < panes.length; i++) { + const p = panes[i]! const idx = i - dp.attach(p.session.name) - dp.onFrame = (lines) => { + p.directPane.attach(p.session.name) + p.directPane.onFrame = (lines) => { if (!this.running) return this.drawPane(idx, lines) } @@ -118,21 +169,24 @@ export class DirectGridRenderer { get focusIndex() { return this._focusIndex } get paneCount() { return this.panes.length } + get totalPaneCount() { let n = 0; for (const [, p] of this.tabPanes) n += p.length; return n } get focusedPane(): GridPaneInfo | null { return this.panes[this._focusIndex] ?? null } get selectMode() { return this._selectMode } get isExpanded() { return this._expandedIndex >= 0 } + get isSoftExpanded() { return this._softExpandIndex >= 0 } + get activeTabId() { return this._activeTabId } enterSelectMode() { - if (!this.isExpanded) return // Only allow in expanded mode + if (!this.isExpanded) return this._selectMode = true - this.writeRaw("\x1b[?1000l\x1b[?1006l") // Disable mouse reporting + this.writeRaw("\x1b[?1000l\x1b[?1006l") this.writeRaw(SHOW_CURSOR) this.drawChrome() } exitSelectMode() { this._selectMode = false - this.writeRaw("\x1b[?1000h\x1b[?1006h") // Re-enable mouse reporting + this.writeRaw("\x1b[?1000h\x1b[?1006h") this.writeRaw(HIDE_CURSOR) this.drawChrome() } @@ -141,6 +195,7 @@ export class DirectGridRenderer { const idx = index ?? this._focusIndex if (idx < 0 || idx >= this.panes.length) return this._expandedIndex = idx + this._softExpandIndex = -1 this._focusIndex = idx this.repositionAll() } @@ -148,11 +203,103 @@ export class DirectGridRenderer { collapsePane() { if (this._selectMode) this.exitSelectMode() this._expandedIndex = -1 + this._softExpandIndex = -1 this.repositionAll() } + // ─── Soft Expand ────────────────────────────────────── + + softExpandPane(index: number) { + if (index < 0 || index >= this.panes.length) return + this._softExpandIndex = index + this._focusIndex = index + this.repositionAll() + } + + softCollapsePane() { + this._softExpandIndex = -1 + this.repositionAll() + } + + toggleSoftExpand(index: number) { + if (this._softExpandIndex === index) this.softCollapsePane() + else this.softExpandPane(index) + } + + // ─── Tab management ─────────────────────────────────── + + addTab(tab: GridTab) { + this.tabPanes.set(tab.id, []) + this.tabFocus.set(tab.id, 0) + this.tabExpanded.set(tab.id, -1) + this.tabSoftExpand.set(tab.id, -1) + } + + removeTab(tabId: number) { + const panes = this.tabPanes.get(tabId) + if (panes) { + for (const p of panes) { + p.directPane.detach() + stopCapture(p.session.name) + killSession(p.session.name) + this.clearFlash(p.session.name) + } + } + this.tabPanes.delete(tabId) + this.tabFocus.delete(tabId) + this.tabExpanded.delete(tabId) + this.tabSoftExpand.delete(tabId) + } + + setActiveTab(tabId: number) { + if (this._activeTabId === tabId) return + // Detach current tab's panes + if (this._activeTabId >= 0) { + for (const p of this.panes) p.directPane.detach() + } + this._activeTabId = tabId + // Reattach new tab's panes + const panes = this.panes + for (let i = 0; i < panes.length; i++) { + const p = panes[i]! + const idx = i + p.directPane.attach(p.session.name) + p.directPane.onFrame = (lines) => { + if (!this.running) return + this.drawPane(idx, lines) + } + } + if (this.running) { + this.writeRaw(CLEAR) + this.repositionAll() + } + } + + getTabPaneCount(tabId: number): number { + return this.tabPanes.get(tabId)?.length ?? 0 + } + + hasIdleInTab(tabId: number): boolean { + const panes = this.tabPanes.get(tabId) + if (!panes) return false + return panes.some(p => p.status === "idle") + } + // 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 { + checkButtonClick(col: number, row: number): { action: "max" | "min" | "sel" | "tab" | "newtab", paneIndex: number, tabId?: number } | null { + // Tab bar check (row 1) + if (row === 1) { + for (const region of this.tabBarHitRegions) { + if (col >= region.startCol && col <= region.endCol) { + return { action: "tab", paneIndex: -1, tabId: region.tabId } + } + } + if (this.tabBarAddBtnCol > 0 && col >= this.tabBarAddBtnCol && col <= this.tabBarAddBtnCol + 2) { + return { action: "newtab", paneIndex: -1 } + } + return null + } + const indicesToCheck = this.isExpanded ? [this._expandedIndex] : this.panes.map((_, i) => i) for (const i of indicesToCheck) { const dp = this.panes[i]!.directPane @@ -164,8 +311,6 @@ export class DirectGridRenderer { 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 } @@ -173,7 +318,6 @@ export class DirectGridRenderer { 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 } @@ -184,50 +328,67 @@ export class DirectGridRenderer { // ─── Pane management ─────────────────────────────────── - async addPane(session: PtySession): Promise { - const regions = this.calcPaneRegions(this.panes.length + 1) - const idx = this.panes.length - const region = regions[idx]! - - const dp = new DirectPane(region.screenX, region.screenY, region.contentW, region.contentH) - const info: GridPaneInfo = { session, directPane: dp, status: null, statusSince: 0 } - this.panes.push(info) - - // Resize PTY to match content area - resizeSession(session.name, region.contentW, region.contentH) - - // Start capture (reads PTY stdout and pushes frames) - startCapture(session) - - // Subscribe to push frames (must set callback AFTER attach, since attach calls detach which nulls onFrame) - dp.attach(session.name) - dp.onFrame = (lines) => { - if (!this.running) return - this.drawPane(idx, lines) + async addPane(session: PtySession, tabId?: number): Promise { + const tid = tabId ?? this._activeTabId + let panes = this.tabPanes.get(tid) + if (!panes) { + panes = [] + this.tabPanes.set(tid, []) + this.tabFocus.set(tid, 0) + this.tabExpanded.set(tid, -1) + this.tabSoftExpand.set(tid, -1) + panes = this.tabPanes.get(tid)! } - // Reposition all existing panes - this.repositionAll() + const isActive = tid === this._activeTabId + const regions = isActive ? this.calcPaneRegions(panes.length + 1) : [{ screenX: 2, screenY: 5, contentW: 20, contentH: 6 }] + const idx = panes.length + const region = regions[Math.min(idx, regions.length - 1)]! + + const dp = new DirectPane(region.screenX, region.screenY, region.contentW, region.contentH) + const info: GridPaneInfo = { session, directPane: dp, status: null, statusSince: 0, tabId: tid } + panes.push(info) + + resizeSession(session.name, region.contentW, region.contentH) + startCapture(session) + + if (isActive) { + dp.attach(session.name) + dp.onFrame = (lines) => { + if (!this.running) return + this.drawPane(idx, lines) + } + this.repositionAll() + } return info } removePane(sessionName: string) { - const idx = this.panes.findIndex(p => p.session.name === sessionName) - if (idx < 0) return + // Search across all tabs + for (const [tabId, panes] of this.tabPanes) { + const idx = panes.findIndex(p => p.session.name === sessionName) + if (idx < 0) continue - const pane = this.panes[idx]! - pane.directPane.detach() - stopCapture(pane.session.name) - killSession(pane.session.name) - this.clearFlash(sessionName) - this.panes.splice(idx, 1) + const pane = panes[idx]! + pane.directPane.detach() + stopCapture(pane.session.name) + killSession(pane.session.name) + this.clearFlash(sessionName) + panes.splice(idx, 1) - if (this._focusIndex >= this.panes.length) { - this._focusIndex = Math.max(0, this.panes.length - 1) + if (tabId === this._activeTabId) { + const fi = this.tabFocus.get(tabId) ?? 0 + if (fi >= panes.length) this.tabFocus.set(tabId, Math.max(0, panes.length - 1)) + // Reset expand states if they reference removed pane + const ei = this.tabExpanded.get(tabId) ?? -1 + if (ei >= panes.length || ei === idx) this.tabExpanded.set(tabId, -1) + const si = this.tabSoftExpand.get(tabId) ?? -1 + if (si >= panes.length || si === idx) this.tabSoftExpand.set(tabId, -1) + this.repositionAll() + } + return } - - this.repositionAll() } // ─── Focus ───────────────────────────────────────────── @@ -249,6 +410,35 @@ export class DirectGridRenderer { } focusByDirection(dir: "up" | "down" | "left" | "right") { + if (this.isSoftExpanded) { + // In soft expand: left/right toggles between expanded and strips + const sei = this._softExpandIndex + const strips = this.panes.map((_, i) => i).filter(i => i !== sei) + if (strips.length === 0) return + + if (dir === "left" || dir === "right") { + if (dir === "right" && this._focusIndex === sei) { + this.setFocus(strips[0]!) + } else if (dir === "left" && this._focusIndex !== sei) { + this.setFocus(sei) + } else { + const curStripIdx = strips.indexOf(this._focusIndex) + const nextIdx = (curStripIdx + 1) % strips.length + this.setFocus(strips[nextIdx]!) + } + return + } + // Up/down navigates within strips + if (this._focusIndex === sei) { + this.setFocus(strips[0]!) + return + } + const curStripIdx = strips.indexOf(this._focusIndex) + if (dir === "down") this.setFocus(strips[(curStripIdx + 1) % strips.length]!) + else this.setFocus(strips[(curStripIdx - 1 + strips.length) % strips.length]!) + return + } + const n = this.panes.length if (n <= 1) return const { cols } = this.calcGrid(n) @@ -267,6 +457,22 @@ export class DirectGridRenderer { } focusByClick(col: number, row: number): boolean { + if (this.isSoftExpanded) { + // Hit-test against actual pane positions in soft expand layout + for (let i = 0; i < this.panes.length; i++) { + const dp = this.panes[i]!.directPane + const bx = dp.screenX - 1 + const by = dp.screenY - 3 + const bw = dp.width + 2 + const bh = dp.height + 4 + if (col >= bx && col < bx + bw && row >= by && row < by + bh) { + this.setFocus(i) + return true + } + } + return false + } + const n = this.panes.length if (n === 0) return false const termW = process.stdout.columns || 120 @@ -285,6 +491,33 @@ export class DirectGridRenderer { return false } + // Determine which pane index was clicked (for soft expand) + getPaneIndexAtClick(col: number, row: number): number { + if (this.isSoftExpanded) { + for (let i = 0; i < this.panes.length; i++) { + const dp = this.panes[i]!.directPane + const bx = dp.screenX - 1 + const by = dp.screenY - 3 + const bw = dp.width + 2 + const bh = dp.height + 4 + if (col >= bx && col < bx + bw && row >= by && row < by + bh) return i + } + return -1 + } + const n = this.panes.length + if (n === 0) return -1 + 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 + return (idx >= 0 && idx < n) ? idx : -1 + } + // ─── Chrome ──────────────────────────────────────────── drawChrome() { @@ -294,7 +527,10 @@ export class DirectGridRenderer { let out = SYNC_START - // Header (row 1) + // Tab bar (row 1) + out += this.drawTabBar(termW) + + // Header (row 2) const n = this.panes.length const fi = this._focusIndex + 1 let headerLeft: string, headerRight: string @@ -303,12 +539,15 @@ export class DirectGridRenderer { 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}` + headerRight = `${DIM}click ${BOLD}[SEL]${RESET}${DIM} select text │ click ${BOLD}[MIN]${RESET}${DIM} restore │ ctrl+space picker${RESET}` + } else if (this.isSoftExpanded) { + headerLeft = ` ${BOLD}cladm grid${RESET} — ${hexFg("#bb9af7")}${BOLD}FOCUS${RESET} │ ${fi}/${n}` + headerRight = `${DIM}click pane to focus │ click ${BOLD}[MAX]${RESET}${DIM} fullscreen │ ctrl+e toggle${RESET}` } 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}` + headerRight = `${DIM}shift+arrows nav │ click ${BOLD}[MAX]${RESET}${DIM} expand │ ctrl+space picker │ ctrl+w close${RESET}` } - out += `\x1b[1;1H\x1b[${termW}X${headerLeft} ${headerRight}` + out += `\x1b[2;1H\x1b[${termW}X${headerLeft} ${headerRight}` // Pane borders + titles if (this.isExpanded) { @@ -329,24 +568,72 @@ export class DirectGridRenderer { } 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}` + const expandNote = app.clickExpand ? `${DIM} │ click-expand: on${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}${expandNote}` } else { - out += `\x1b[${termH};1H\x1b[${termW}X ${DIM}No sessions. Press ctrl+\` to return to picker.${RESET}` + out += `\x1b[${termH};1H\x1b[${termW}X ${DIM}No sessions. Press ctrl+space to return to picker.${RESET}` } out += SYNC_END this.writeRaw(out) } + private drawTabBar(termW: number): string { + this.tabBarHitRegions = [] + this.tabBarAddBtnCol = -1 + + let out = `\x1b[1;1H\x1b[${termW}X ` + let col = 2 + + // Picker tab (id = -1, meaning: switch to picker) + const pickerActive = app.viewMode === "picker" + const pickerLabel = pickerActive ? `${CYAN_FG}${BOLD}● Picker${RESET}` : `${DIM}○ Picker${RESET}` + out += pickerLabel + ` ${DIM}│${RESET} ` + this.tabBarHitRegions.push({ tabId: -1, startCol: col, endCol: col + 7 }) + col += 11 // "● Picker │ " + + // Grid tabs + for (const tab of app.gridTabs) { + const isActive = this._activeTabId === tab.id && app.viewMode === "grid" + const hasIdle = this.hasIdleInTab(tab.id) + const count = this.getTabPaneCount(tab.id) + const label = `${tab.name} (${count})` + + let tabText: string + if (isActive) { + tabText = `${CYAN_FG}${BOLD}● ${label}${RESET}` + } else if (hasIdle) { + tabText = `${YELLOW_FG}◉ ${label}${RESET}` + } else { + tabText = `${DIM}○ ${label}${RESET}` + } + + const startCol = col + out += tabText + ` ${DIM}│${RESET} ` + const visLen = 2 + label.length // "● " + label + this.tabBarHitRegions.push({ tabId: tab.id, startCol, endCol: startCol + visLen - 1 }) + col += visLen + 3 // + " │ " + } + + // [+] button + out += `${DIM}[+]${RESET}` + this.tabBarAddBtnCol = col + col += 3 + + return 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 isSoftExp = this._softExpandIndex === index const color = getColor(pane.session.colorIndex) let borderColor: string if (isFocused) borderColor = WHITE + else if (isSoftExp) borderColor = hexFg("#bb9af7") else if (isFlashing) borderColor = hexFg("#ff9e64") else borderColor = hexFg(color) @@ -368,16 +655,14 @@ export class DirectGridRenderer { 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] + btnVisibleLen = 5 + 1 + 5 } else { - // Grid: [MAX] at right btnSection = `${RESET}${DIM}[MAX]${RESET}${borderColor}` - btnVisibleLen = 5 // [MAX] + btnVisibleLen = 5 } - const hzFill = Math.max(0, bw - 2 - btnVisibleLen - 1) // -2 corners, -1 trailing hz + const hzFill = Math.max(0, bw - 2 - btnVisibleLen - 1) out += `\x1b[${by};${bx}H${borderColor}${tl}${hz.repeat(hzFill)}${btnSection}${hz}${tr}${RESET}` // Title row @@ -417,7 +702,6 @@ export class DirectGridRenderer { // ─── 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 @@ -430,7 +714,6 @@ export class DirectGridRenderer { 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) @@ -442,15 +725,14 @@ export class DirectGridRenderer { 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 + scrollPane(pane.session.name, direction, lines) this.drawChrome() } // ─── Status ──────────────────────────────────────────── markIdle(sessionName: string) { - const pane = this.panes.find(p => p.session.name === sessionName) + const pane = this.findPaneAcrossTabs(sessionName) if (!pane) return if (pane.status !== "idle") { pane.status = "idle"; pane.statusSince = Date.now() } this.startFlash(sessionName) @@ -458,7 +740,7 @@ export class DirectGridRenderer { } markBusy(sessionName: string) { - const pane = this.panes.find(p => p.session.name === sessionName) + const pane = this.findPaneAcrossTabs(sessionName) if (!pane) return if (pane.status !== "busy") { pane.status = "busy"; pane.statusSince = Date.now() } this.clearFlash(sessionName) @@ -466,13 +748,21 @@ export class DirectGridRenderer { } clearMark(sessionName: string) { - const pane = this.panes.find(p => p.session.name === sessionName) + const pane = this.findPaneAcrossTabs(sessionName) if (!pane) return pane.status = null; pane.statusSince = 0 this.clearFlash(sessionName) this.drawChrome() } + private findPaneAcrossTabs(sessionName: string): GridPaneInfo | null { + for (const [, panes] of this.tabPanes) { + const p = panes.find(p => p.session.name === sessionName) + if (p) return p + } + return null + } + startFlash(sessionName: string) { if (this.flashTimers.has(sessionName)) return const timer = setInterval(() => this.drawChrome(), 400) @@ -496,9 +786,10 @@ export class DirectGridRenderer { const n = count ?? this.panes.length const termW = process.stdout.columns || 120 const termH = process.stdout.rows || 40 + const chromeTop = 3 // row 1 = tab bar, row 2 = header, content starts row 3 const { cols, rows } = this.calcGrid(n) const cellW = Math.floor(termW / cols) - const cellH = Math.floor((termH - 2) / rows) + const cellH = Math.floor((termH - chromeTop - 1) / rows) // -1 for footer const regions: { screenX: number, screenY: number, contentW: number, contentH: number }[] = [] for (let i = 0; i < n; i++) { @@ -507,7 +798,7 @@ export class DirectGridRenderer { const contentW = cellW - 2 const contentH = cellH - 4 const screenX = gc * cellW + 2 - const screenY = 2 + gr * cellH + 3 + const screenY = chromeTop + gr * cellH + 3 regions.push({ screenX, screenY, @@ -519,18 +810,55 @@ export class DirectGridRenderer { } repositionAll() { + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const chromeTop = 3 + if (this.isExpanded) { - // Expanded: give the expanded pane full screen area - const termW = process.stdout.columns || 120 - const termH = process.stdout.rows || 40 + // Fullscreen: expanded pane gets all space const contentW = termW - 2 - const contentH = termH - 2 - 4 // -2 header/footer, -4 border chrome + const contentH = termH - chromeTop - 1 - 4 // -1 footer, -4 border chrome const pane = this.panes[this._expandedIndex]! - pane.directPane.reposition(2, 5, Math.max(contentW, 10), Math.max(contentH, 2)) + pane.directPane.reposition(2, chromeTop + 3, 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 if (this.isSoftExpanded) { + // Soft expand: 70/30 split + const sei = this._softExpandIndex + const n = this.panes.length + const availH = termH - chromeTop - 1 // available rows for content + const availW = termW + + // Expanded pane: 70% width, full height + const expandedW = Math.max(Math.floor(availW * 0.7) - 2, 20) + const expandedH = Math.max(availH - 4, 2) + const expandedPane = this.panes[sei]! + expandedPane.directPane.reposition(2, chromeTop + 3, expandedW, expandedH) + resizeSession(expandedPane.session.name, expandedW, expandedH) + resizeCapture(expandedPane.session.name, expandedW, expandedH) + resetHash(`dp_${expandedPane.session.name}`) + + // Strip panes: 30% width, stacked vertically + const strips = this.panes.map((_, i) => i).filter(i => i !== sei) + if (strips.length > 0) { + const stripX = 2 + expandedW + 2 // after expanded pane + border + const stripW = Math.max(availW - expandedW - 4, 10) // remaining width minus borders + const stripCellH = Math.floor(availH / strips.length) + + for (let si = 0; si < strips.length; si++) { + const pi = strips[si]! + const pane = this.panes[pi]! + const stripH = Math.max(stripCellH - 4, 2) + const stripY = chromeTop + si * stripCellH + 3 + pane.directPane.reposition(stripX, stripY, stripW, stripH) + resizeSession(pane.session.name, stripW, stripH) + resizeCapture(pane.session.name, stripW, stripH) + resetHash(`dp_${pane.session.name}`) + } + } } else { + // Equal grid const regions = this.calcPaneRegions() for (let i = 0; i < this.panes.length; i++) { const pane = this.panes[i]! @@ -557,7 +885,10 @@ export class DirectGridRenderer { destroyAll() { this.stop() - this.panes = [] - this._focusIndex = 0 + this.tabPanes.clear() + this.tabFocus.clear() + this.tabExpanded.clear() + this.tabSoftExpand.clear() + this._activeTabId = -1 } } diff --git a/src/grid/view-switch.ts b/src/grid/view-switch.ts index 85dd155..291dee9 100644 --- a/src/grid/view-switch.ts +++ b/src/grid/view-switch.ts @@ -22,13 +22,44 @@ export function switchToGrid() { app.rawStdoutWrite("\x1b[?1000h") app.rawStdoutWrite("\x1b[?1006h") - if (isNew || app.directGrid.paneCount === 0) { - app.directGrid.start() + if (isNew || app.directGrid!.totalPaneCount === 0) { + app.directGrid!.start() } else { - app.directGrid.resume() + app.directGrid!.resume() } } +export function switchToGridTab(tabId: number) { + const tab = app.gridTabs.find(t => t.id === tabId) + if (!tab) return + + // Track last grid tab for Ctrl+Space toggle + app.lastGridTabIndex = app.gridTabs.indexOf(tab) + + if (app.viewMode !== "grid") { + switchToGrid() + } + + app.activeTabIndex = app.gridTabs.indexOf(tab) + 1 + app.directGrid!.setActiveTab(tabId) +} + +export function createNewGridTab(): number { + const tabId = app.nextTabId++ + const tab = { id: tabId, name: `Tab ${tabId}` } + app.gridTabs.push(tab) + + if (!app.directGrid) { + app.directGrid = new DirectGridRenderer(app.rawStdoutWrite) + } + app.directGrid.addTab(tab) + + // Switch to the new tab + switchToGridTab(tabId) + + return tabId +} + export function resizeGridPanes() { if (!app.directGrid || app.directGrid.paneCount === 0) return app.directGrid.repositionAll() diff --git a/src/index.ts b/src/index.ts index b018a37..09bd231 100755 --- a/src/index.ts +++ b/src/index.ts @@ -81,6 +81,12 @@ async function main() { height: "100%", }) + app.tabBarText = new TextRenderable(app.renderer, { + width: "100%", + height: 1, + flexShrink: 0, + }) + app.headerText = new TextRenderable(app.renderer, { width: "100%", height: 1, @@ -148,6 +154,7 @@ async function main() { flexShrink: 0, }) + app.mainBox.add(app.tabBarText) app.mainBox.add(app.headerText) app.mainBox.add(app.colHeaderText) app.mainBox.add(app.listBox) diff --git a/src/input/handlers.ts b/src/input/handlers.ts index 486a110..ddcb933 100644 --- a/src/input/handlers.ts +++ b/src/input/handlers.ts @@ -1,8 +1,8 @@ import type { KeyEvent } from "@opentui/core" import { app } from "../lib/state" -import { updateAll, rebuildDisplayRows, applySortMode } from "../ui/panels" +import { updateAll, rebuildDisplayRows, applySortMode, updateTabBar } from "../ui/panels" import { extractKeyboardInput, extractMouseEvents } from "./parser" -import { switchToGrid } from "../grid/view-switch" +import { switchToGrid, switchToGridTab, createNewGridTab } from "../grid/view-switch" import { doLaunch } from "../actions/launch" import { launchSelections } from "../actions/launcher" import { loadSessions } from "../data/sessions" @@ -136,6 +136,49 @@ export function hitTestListRow(screenRow: number): number { return -1 } +// ─── Tab switching helpers ─────────────────────────────────────────── + +function handleTabSwitch(tabNumber: number) { + if (tabNumber === 0) { + // Switch to picker + if (app.viewMode === "grid") switchToPicker() + return + } + // tabNumber 1-9 → grid tab index 0-8 + const tabIndex = tabNumber - 1 + if (tabIndex < app.gridTabs.length) { + switchToGridTab(app.gridTabs[tabIndex].id) + } +} + +function handleNextTab() { + if (app.gridTabs.length === 0) return + if (app.viewMode === "picker") { + switchToGridTab(app.gridTabs[0].id) + return + } + const currentIdx = app.gridTabs.findIndex(t => t.id === app.directGrid?.activeTabId) + if (currentIdx < app.gridTabs.length - 1) { + switchToGridTab(app.gridTabs[currentIdx + 1].id) + } else { + switchToPicker() // wrap around to picker + } +} + +function handlePrevTab() { + if (app.gridTabs.length === 0) return + if (app.viewMode === "picker") { + switchToGridTab(app.gridTabs[app.gridTabs.length - 1].id) + return + } + const currentIdx = app.gridTabs.findIndex(t => t.id === app.directGrid?.activeTabId) + if (currentIdx > 0) { + switchToGridTab(app.gridTabs[currentIdx - 1].id) + } else { + switchToPicker() // wrap to picker + } +} + // ─── Picker click ──────────────────────────────────────────────────── export function handlePickerClick(_col: number, screenRow: number) { @@ -146,6 +189,39 @@ export function handlePickerClick(_col: number, screenRow: number) { updateAll() } +// ─── Picker tab bar click ──────────────────────────────────────────── + +function handlePickerTabBarClick(col: number, screenRow: number) { + // Tab bar is at row 1 in picker (rendered as OpenTUI text) + if (screenRow !== 1) return false + // Hit test against tab bar positions (approximate, since OpenTUI renders it) + // We compute positions similar to the grid tab bar + let c = 2 + // Picker tab + const pickerEnd = c + 7 + if (col >= c && col <= pickerEnd) return false // already on picker + c += 11 + + for (const tab of app.gridTabs) { + const count = app.directGrid?.getTabPaneCount(tab.id) ?? 0 + const label = `${tab.name} (${count})` + const visLen = 2 + label.length + if (col >= c && col < c + visLen) { + switchToGridTab(tab.id) + return true + } + c += visLen + 3 + } + + // [+] button + if (col >= c && col <= c + 2) { + createNewGridTab() + return true + } + + return false +} + // ─── Keyboard ──────────────────────────────────────────────────────── export async function handleKeypress(key: KeyEvent) { @@ -284,10 +360,6 @@ export async function handleKeypress(key: KeyEvent) { app.renderer.destroy() return - case "t": - if (app.directGrid && app.directGrid.paneCount > 0) switchToGrid() - return - default: return } @@ -301,37 +373,82 @@ export async function handleKeypress(key: KeyEvent) { export async function handleGridInput(rawSequence: string): Promise { if (app.viewMode !== "grid" || !app.directGrid) return false - if (rawSequence === "\x1b" && app.directGrid.isExpanded) { - app.directGrid.collapsePane() + // Esc: collapse expanded/soft-expanded, or do nothing + if (rawSequence === "\x1b") { + if (app.directGrid.isExpanded) { app.directGrid.collapsePane(); return true } + if (app.directGrid.isSoftExpanded) { app.directGrid.softCollapsePane(); return true } return true } - if (rawSequence === "\x1e" || rawSequence === "\x1b`" || rawSequence === "\x00") { + // Ctrl+Space → switch to picker + if (rawSequence === "\x00") { + app.lastGridTabIndex = app.gridTabs.findIndex(t => t.id === app.directGrid!.activeTabId) switchToPicker() return true } + // Ctrl+T → new tab + if (rawSequence === "\x14") { + createNewGridTab() + return true + } + + // Ctrl+E → toggle click-to-expand + if (rawSequence === "\x05") { + app.clickExpand = !app.clickExpand + if (!app.clickExpand && app.directGrid.isSoftExpanded) app.directGrid.softCollapsePane() + app.directGrid.drawChrome() + return true + } + + // Alt+1 through Alt+9 → switch tab + if (rawSequence.length === 2 && rawSequence[0] === "\x1b" && rawSequence[1] >= "1" && rawSequence[1] <= "9") { + handleTabSwitch(parseInt(rawSequence[1])) + return true + } + + // Alt+n → next tab, Alt+p → prev tab + if (rawSequence === "\x1bn") { handleNextTab(); return true } + if (rawSequence === "\x1bp") { handlePrevTab(); return true } + + // Ctrl+N / Ctrl+P → focus next/prev pane if (rawSequence === "\x0e") { app.directGrid.focusNext(); return true } if (rawSequence === "\x10") { app.directGrid.focusPrev(); return true } + // Ctrl+F → open folder if (rawSequence === "\x06") { const pane = app.directGrid.focusedPane if (pane) Bun.spawn(["open", pane.session.projectPath]) return true } + // Ctrl+W → close pane (remove tab if last pane) if (rawSequence === "\x17") { const pane = app.directGrid.focusedPane if (pane) { if (app.directGrid.isExpanded) app.directGrid.collapsePane() + if (app.directGrid.isSoftExpanded) app.directGrid.softCollapsePane() const { killSession } = await import("../pty/session-manager") app.directGrid.removePane(pane.session.name) await killSession(pane.session.name) - if (app.directGrid.paneCount === 0) switchToPicker() + if (app.directGrid.paneCount === 0) { + // Remove current tab and switch to previous or picker + const currentTabId = app.directGrid.activeTabId + const tabIdx = app.gridTabs.findIndex(t => t.id === currentTabId) + app.directGrid.removeTab(currentTabId) + app.gridTabs.splice(tabIdx, 1) + if (app.gridTabs.length > 0) { + const prevIdx = Math.max(0, tabIdx - 1) + switchToGridTab(app.gridTabs[prevIdx].id) + } else { + switchToPicker() + } + } } return true } + // Page Up/Down → scroll if (rawSequence === "\x1b[5~") { app.directGrid.sendScrollToFocused("up"); return true } if (rawSequence === "\x1b[6~") { app.directGrid.sendScrollToFocused("down"); return true } @@ -343,9 +460,10 @@ export async function handleGridInput(rawSequence: string): Promise { export function switchToPicker() { app.viewMode = "picker" + app.activeTabIndex = 0 if (app.directGrid) { if (app.directGrid.selectMode) app.directGrid.exitSelectMode() - if (app.directGrid.paneCount > 0) app.directGrid.pause() + if (app.directGrid.totalPaneCount > 0) app.directGrid.pause() } app.renderer.resume() process.stdin.removeAllListeners("data") @@ -376,7 +494,27 @@ function processGridInput(str: string) { if (btn?.action === "max") dg.expandPane(btn.paneIndex) else if (btn?.action === "min") dg.collapsePane() else if (btn?.action === "sel") dg.enterSelectMode() - else dg.focusByClick(me.col, me.row) + else if (btn?.action === "tab") { + if (btn.tabId === -1) { + // Switch to picker + app.lastGridTabIndex = app.gridTabs.findIndex(t => t.id === dg.activeTabId) + switchToPicker() + } else if (btn.tabId !== undefined) { + switchToGridTab(btn.tabId) + } + } + else if (btn?.action === "newtab") createNewGridTab() + else { + // Pane body click + if (app.clickExpand && !dg.isExpanded) { + const clickedIdx = dg.getPaneIndexAtClick(me.col, me.row) + if (clickedIdx >= 0) { + dg.toggleSoftExpand(clickedIdx) + } + } else { + dg.focusByClick(me.col, me.row) + } + } continue } } @@ -397,15 +535,24 @@ function processGridInput(str: string) { // ─── Stdin: picker mode ────────────────────────────────────────────── function processPickerInput(str: string) { - // Ctrl+Space → toggle to grid - if (str.includes("\x00") && app.directGrid && app.directGrid.paneCount > 0) { - switchToGrid() - return + // Ctrl+Space → toggle to last grid tab + if (str.includes("\x00")) { + if (app.directGrid && app.directGrid.totalPaneCount > 0) { + // Switch to last active grid tab + if (app.gridTabs.length > 0) { + const idx = Math.min(app.lastGridTabIndex, app.gridTabs.length - 1) + switchToGridTab(app.gridTabs[Math.max(0, idx)].id) + } + return + } } const pickerMouse = extractMouseEvents(str) for (const me of pickerMouse) { - if (me.btn === 0 && !me.release) handlePickerClick(me.col, me.row) + if (me.btn === 0 && !me.release) { + if (handlePickerTabBarClick(me.col, me.row)) continue + handlePickerClick(me.col, me.row) + } if (me.btn === 64) { if (app.cursor > 0) { app.cursor--; updateAll() } } if (me.btn === 65) { if (app.cursor < app.displayRows.length - 1) { app.cursor++; updateAll() } } } @@ -413,8 +560,21 @@ function processPickerInput(str: string) { const keyboard = extractKeyboardInput(str) if (!keyboard) return + // Check for Alt+digit and Alt+n/p before normal key processing let ki = 0 while (ki < keyboard.length) { + // Alt sequences + if (keyboard[ki] === "\x1b" && ki + 1 < keyboard.length) { + const next = keyboard[ki + 1] + if (next >= "1" && next <= "9") { + handleTabSwitch(parseInt(next)) + ki += 2 + continue + } + if (next === "n") { handleNextTab(); ki += 2; continue } + if (next === "p") { handlePrevTab(); ki += 2; continue } + } + let matched = false for (let len = Math.min(8, keyboard.length - ki); len >= 1; len--) { const mapped = KEY_MAP[keyboard.slice(ki, ki + len)] diff --git a/src/input/parser.ts b/src/input/parser.ts index c1b0b2a..ad62e74 100644 --- a/src/input/parser.ts +++ b/src/input/parser.ts @@ -65,9 +65,9 @@ export function extractKeyboardInput(data: string): string { i += 3; continue } - // \x1b` (ctrl+backtick) — keep as keyboard shortcut - if (next === "`") { - keyboard += "\x1b`" + // Alt+digit (1-9) and Alt+letter (n, p) — keep as keyboard shortcuts + if ((next >= "1" && next <= "9") || next === "n" || next === "p") { + keyboard += data.slice(i, i + 2) i += 2; continue } diff --git a/src/lib/state.ts b/src/lib/state.ts index 66867b9..be22839 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -7,6 +7,11 @@ import type { IdleSessionInfo } from "../data/monitor" export type ViewMode = "picker" | "grid" +export interface GridTab { + id: number + name: string +} + export const app = { // Config demoMode: Bun.argv.includes("--demo"), @@ -36,9 +41,17 @@ export const app = { mainBox: null as BoxRenderable | null, rawStdoutWrite: null as unknown as (s: string) => boolean, + // Tabs + activeTabIndex: 0, // 0 = picker, 1+ = grid tab index+1 + gridTabs: [] as GridTab[], // grid tabs only (not picker) + nextTabId: 1, // auto-increment for tab ids + clickExpand: true, // click-to-expand feature toggle + lastGridTabIndex: 0, // last active grid tab for Ctrl+Space toggle + // UI refs (set during init) renderer: null as unknown as CliRenderer, headerText: null as unknown as TextRenderable, + tabBarText: null as unknown as TextRenderable, colHeaderText: null as unknown as TextRenderable, listBox: null as unknown as ScrollBoxRenderable, bottomRow: null as unknown as BoxRenderable, diff --git a/src/ui/panels.ts b/src/ui/panels.ts index c44810e..b270f95 100644 --- a/src/ui/panels.ts +++ b/src/ui/panels.ts @@ -67,6 +67,38 @@ export function applySortMode() { rebuildDisplayRows() } +// ─── Tab bar ───────────────────────────────────────────────────────── + +export function updateTabBar() { + if (!app.tabBarText) return + + // Build tab bar segments using styled text + const sep = dim(" │ ") + const pickerActive = app.viewMode === "picker" + const pickerTab = pickerActive ? t`${cyan("●")} ${bold("Picker")}` : t`${dim("○ Picker")}` + + // Start with picker + let content = t` ${pickerTab}` + + // Grid tabs + for (const tab of app.gridTabs) { + const count = app.directGrid?.getTabPaneCount(tab.id) ?? 0 + const hasIdle = app.directGrid?.hasIdleInTab(tab.id) ?? false + const isActive = app.viewMode === "grid" && app.directGrid?.activeTabId === tab.id + const label = `${tab.name} (${count})` + if (isActive) { + content = t`${content}${sep}${cyan("●")} ${bold(label)}` + } else if (hasIdle) { + content = t`${content}${sep}${yellow("◉")} ${label}` + } else { + content = t`${content}${sep}${dim("○ " + label)}` + } + } + + content = t`${content}${sep}${dim("[+]")}` + app.tabBarText.content = content +} + // ─── Header / Footer ───────────────────────────────────────────────── export function updateHeader() { @@ -93,7 +125,7 @@ export function updateColumnHeaders() { } export function updateFooter() { - const gridHint = app.directGrid && app.directGrid.paneCount > 0 ? " │ ^space grid" : "" + const gridHint = app.directGrid && app.directGrid.totalPaneCount > 0 ? " │ ^space grid" : "" if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0) { app.footerText.content = t` ${dim( "↑↓ nav │ tab/shift-tab idle select │ enter focus │ i preview │ space select │ a all │ n none │ s sort │ q quit" + gridHint @@ -327,6 +359,7 @@ function clearChildren(box: { getChildren(): { id: string }[]; remove(id: string export function updateAll() { if (app.destroyed) return + updateTabBar() updateHeader() rebuildList() updateBottomPanel() From f5dd49d870fb01514cd18e2360dd5334846905ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 28 Feb 2026 10:57:32 +0000 Subject: [PATCH 07/13] feat: numbered tab selection for launching projects into separate grid tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Space cycles tab number (1→2→...→9→off), digit keys 1-9 assign directly. Each number gets a unique color in the picker brackets. On launch, projects are grouped by tab number into separate grid tabs. Co-Authored-By: Claude Opus 4.6 --- src/actions/launch.ts | 72 +++++++++++++++++++++++++---------------- src/actions/launcher.ts | 8 ++--- src/input/handlers.ts | 35 ++++++++++++++++++-- src/lib/state.ts | 2 +- src/ui/formatters.ts | 28 +++++++++++++--- src/ui/panels.ts | 11 ++++--- 6 files changed, 112 insertions(+), 44 deletions(-) diff --git a/src/actions/launch.ts b/src/actions/launch.ts index b3f89c5..db87844 100644 --- a/src/actions/launch.ts +++ b/src/actions/launch.ts @@ -15,9 +15,10 @@ export async function doLaunch() { return } - const items: { path: string; name: string; sessionId?: string; targetBranch?: string }[] = [] + type LaunchItem = { path: string; name: string; tabNum: number; sessionId?: string; targetBranch?: string } + const items: LaunchItem[] = [] - for (const path of app.selectedProjects) { + for (const [path, tabNum] of app.selectedProjects) { const project = app.projects.find(p => p.path === path) if (!project) continue const targetBranch = app.selectedBranches.get(path) @@ -27,7 +28,7 @@ export async function doLaunch() { project.sessionCount = project.sessions.length } const lastSessionId = project.sessions[0]?.id - items.push({ path, name: project.name, sessionId: lastSessionId, targetBranch: needsBranch ? targetBranch : undefined }) + items.push({ path, name: project.name, tabNum, sessionId: lastSessionId, targetBranch: needsBranch ? targetBranch : undefined }) } for (const project of app.projects) { @@ -36,41 +37,58 @@ export async function doLaunch() { if (app.selectedSessions.has(session.id)) { const targetBranch = app.selectedBranches.get(project.path) const needsBranch = targetBranch && targetBranch !== project.branch - items.push({ path: project.path, name: project.name, sessionId: session.id, targetBranch: needsBranch ? targetBranch : undefined }) + // Sessions without explicit tab number go to tab 1 + items.push({ path: project.path, name: project.name, tabNum: 1, sessionId: session.id, targetBranch: needsBranch ? targetBranch : undefined }) } } } if (items.length === 0) return - // Determine target tab: use active grid tab or create a new one - let targetTabId: number - if (app.viewMode === "grid" && app.directGrid && app.gridTabs.length > 0) { - targetTabId = app.directGrid.activeTabId - } else { - targetTabId = createNewGridTab() + // Group items by tab number + const byTab = new Map() + for (const item of items) { + if (!byTab.has(item.tabNum)) byTab.set(item.tabNum, []) + byTab.get(item.tabNum)!.push(item) } ensureGridView() - const termW = process.stdout.columns || 120 - const termH = process.stdout.rows || 40 - const totalPanes = items.length + (app.directGrid?.getTabPaneCount(targetTabId) || 0) - const cols = totalPanes <= 1 ? 1 : totalPanes <= 2 ? 2 : totalPanes <= 4 ? 2 : 3 - const rows = Math.ceil(totalPanes / cols) - const paneW = Math.max(Math.floor(termW / cols) - 2, 20) - const paneH = Math.max(Math.floor((termH - 2) / rows) - 4, 6) + // Launch each tab group into its own grid tab + for (const [tabNum, tabItems] of byTab) { + // Find existing grid tab for this number, or create one + let targetTabId: number + const existingTab = app.gridTabs.find(t => t.name === `Tab ${tabNum}`) + if (existingTab) { + targetTabId = existingTab.id + } else { + targetTabId = createNewGridTab() + // Rename to match the picker tab number + const tab = app.gridTabs.find(t => t.id === targetTabId) + if (tab) tab.name = `Tab ${tabNum}` + } - for (const item of items) { - const session = await createSession({ - projectPath: item.path, - projectName: item.name, - sessionId: item.sessionId, - targetBranch: item.targetBranch, - width: paneW, - height: paneH, - }) - await app.directGrid!.addPane(session, targetTabId) + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const totalPanes = tabItems.length + (app.directGrid?.getTabPaneCount(targetTabId) || 0) + const cols = totalPanes <= 1 ? 1 : totalPanes <= 2 ? 2 : totalPanes <= 4 ? 2 : 3 + const rows = Math.ceil(totalPanes / cols) + const paneW = Math.max(Math.floor(termW / cols) - 2, 20) + const paneH = Math.max(Math.floor((termH - 2) / rows) - 4, 6) + + for (const item of tabItems) { + const session = await createSession({ + projectPath: item.path, + projectName: item.name, + sessionId: item.sessionId, + targetBranch: item.targetBranch, + width: paneW, + height: paneH, + }) + await app.directGrid!.addPane(session, targetTabId) + } + + switchToGridTab(targetTabId) } app.selectedProjects.clear() diff --git a/src/actions/launcher.ts b/src/actions/launcher.ts index 260a49a..29fab27 100644 --- a/src/actions/launcher.ts +++ b/src/actions/launcher.ts @@ -9,13 +9,13 @@ interface LaunchItem { export async function launchSelections( projects: Project[], - selectedProjects: Set, + selectedProjects: Map, selectedSessions: Set, selectedBranches: Map = new Map() ): Promise { const byProject = new Map() - for (const path of selectedProjects) { + for (const [path] of selectedProjects) { if (!byProject.has(path)) byProject.set(path, []) const targetBranch = selectedBranches.get(path) const project = projects.find(p => p.path === path) @@ -50,7 +50,7 @@ export async function launchSelections( let count = 0 for (const [, items] of byProject) { - const first = items[0] + const first = items[0]! const firstCmd = buildCmd(first) const newWindowScript = [ @@ -65,7 +65,7 @@ export async function launchSelections( for (let i = 1; i < items.length; i++) { await Bun.sleep(400) - const cmd = buildCmd(items[i]) + const cmd = buildCmd(items[i]!) await runOsascript( 'tell application "System Events" to keystroke "t" using command down' diff --git a/src/input/handlers.ts b/src/input/handlers.ts index ddcb933..e058593 100644 --- a/src/input/handlers.ts +++ b/src/input/handlers.ts @@ -56,10 +56,20 @@ function toggleSetItem(set: Set, item: T) { else set.add(item) } +const MAX_TAB_NUM = 9 + function toggleRowSelection(row: DisplayRow) { const project = app.projects[row.projectIndex] if (row.type === "project" || row.type === "new-session") { - toggleSetItem(app.selectedProjects, project.path) + // Cycle tab number: none → 1 → 2 → ... → 9 → none + const current = app.selectedProjects.get(project.path) + if (current === undefined) { + app.selectedProjects.set(project.path, 1) + } else if (current < MAX_TAB_NUM) { + app.selectedProjects.set(project.path, current + 1) + } else { + app.selectedProjects.delete(project.path) + } } else if (row.type === "session") { toggleSetItem(app.selectedSessions, project.sessions![row.sessionIndex!].id) } else if (row.type === "branch") { @@ -71,6 +81,18 @@ function toggleRowSelection(row: DisplayRow) { } } +function assignTabNumber(row: DisplayRow, tabNum: number) { + const project = app.projects[row.projectIndex] + if (row.type === "project" || row.type === "new-session") { + const current = app.selectedProjects.get(project.path) + if (current === tabNum) { + app.selectedProjects.delete(project.path) // toggle off if same number + } else { + app.selectedProjects.set(project.path, tabNum) + } + } +} + function syntheticKey(name: string, shift = false, ctrl = false): KeyEvent { return { name, shift, ctrl, meta: false, preventDefault: NOOP, stopPropagation: NOOP } as KeyEvent } @@ -290,7 +312,7 @@ export async function handleKeypress(key: KeyEvent) { } case "a": - for (const p of app.projects) app.selectedProjects.add(p.path) + for (const p of app.projects) app.selectedProjects.set(p.path, 1) break case "n": @@ -339,7 +361,7 @@ export async function handleKeypress(key: KeyEvent) { case "o": { if (app.selectedProjects.size === 0 && app.selectedSessions.size === 0) { const oRow = app.displayRows[app.cursor] - if (oRow) app.selectedProjects.add(app.projects[oRow.projectIndex].path) + if (oRow) app.selectedProjects.set(app.projects[oRow.projectIndex].path, 1) } if (app.selectedProjects.size > 0 || app.selectedSessions.size > 0) { await launchSelections(app.projects, app.selectedProjects, app.selectedSessions, app.selectedBranches) @@ -350,6 +372,13 @@ export async function handleKeypress(key: KeyEvent) { break } + case "1": case "2": case "3": case "4": case "5": + case "6": case "7": case "8": case "9": { + const row = app.displayRows[app.cursor] + assignTabNumber(row, parseInt(key.name)) + break + } + case "q": case "escape": app.destroyed = true diff --git a/src/lib/state.ts b/src/lib/state.ts index be22839..65f2fb8 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -18,7 +18,7 @@ export const app = { // Data projects: [] as Project[], - selectedProjects: new Set(), + selectedProjects: new Map(), // path → tab number selectedSessions: new Set(), selectedBranches: new Map(), cursor: 0, diff --git a/src/ui/formatters.ts b/src/ui/formatters.ts index 9e8d475..8acc010 100644 --- a/src/ui/formatters.ts +++ b/src/ui/formatters.ts @@ -22,7 +22,25 @@ export function fmtSyncIndicator(ahead: number, behind: number): string { return parts.join("") } -export function fmtProjectRow(project: import("../lib/types").Project, isSelected: boolean) { +const TAB_COLORS = [ + cyan, // 1 + green, // 2 + yellow, // 3 + magenta, // 4 + (s: string) => fg("#ff9e64")(s), // 5 + (s: string) => fg("#7dcfff")(s), // 6 + (s: string) => fg("#bb9af7")(s), // 7 + (s: string) => fg("#73daca")(s), // 8 + (s: string) => fg("#b4f9f8")(s), // 9 +] + +function fmtTabCheck(tabNum: number | undefined) { + if (tabNum === undefined) return " " + const color = TAB_COLORS[(tabNum - 1) % TAB_COLORS.length]! + return color(String(tabNum)) +} + +export function fmtProjectRow(project: import("../lib/types").Project, isSelected: number | undefined) { let activeDot: string let activeTag: string if (project.activeSessions > 0) { @@ -39,7 +57,7 @@ export function fmtProjectRow(project: import("../lib/types").Project, isSelecte activeDot = dim("○") activeTag = " " } - const check = isSelected ? green("✓") : " " + const check = fmtTabCheck(isSelected) const arrow = project.expanded ? "▼" : "▶" const name = project.name.length > 28 ? project.name.slice(0, 25) + "..." : project.name @@ -80,7 +98,7 @@ export function fmtSessionRow( ) { const project = app.projects[projectIdx] const session = project.sessions![sessionIdx] - const check = isSelected ? green("✓") : " " + const check = isSelected ? green("✓") : " " // sessions still use boolean check const prefix = isLastSession ? "│ " : "├─" const title = session.title.length > 55 @@ -123,8 +141,8 @@ export function fmtSessionRow( ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}` } -export function fmtNewSessionRow(projectIdx: number, isSelected: boolean) { - const check = isSelected ? green("✓") : " " +export function fmtNewSessionRow(projectIdx: number, isSelected: number | undefined) { + const check = fmtTabCheck(isSelected) return t` ${dim("└─")} [${check}] ${green("+ New session")}` } diff --git a/src/ui/panels.ts b/src/ui/panels.ts index b270f95..fd9ce28 100644 --- a/src/ui/panels.ts +++ b/src/ui/panels.ts @@ -103,17 +103,20 @@ export function updateTabBar() { export function updateHeader() { const total = app.selectedProjects.size + app.selectedSessions.size + // Count distinct tab groups + const tabGroups = new Set(app.selectedProjects.values()) + const tabNote = tabGroups.size > 1 ? ` → ${tabGroups.size} tabs` : "" const branchNote = app.selectedBranches.size > 0 ? ` (${app.selectedBranches.size} branch switch)` : "" const modeLabel = app.demoMode ? " [DEMO]" : "" const activeCount = app.projects.reduce((sum, p) => sum + (p.activeSessions > 0 ? 1 : 0), 0) const busyCount = app.projects.reduce((sum, p) => sum + (p.busySessions > 0 ? 1 : 0), 0) const idleCount = activeCount - busyCount if (activeCount > 0) { - app.headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${String(total)} selected${branchNote} ${dim( + app.headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${String(total)} selected${tabNote}${branchNote} ${dim( `sort: ${app.sortLabels[app.sortMode]} │ ${app.projects.length} projects` )} │ ${green(`${busyCount} busy`)} ${yellow(`${idleCount} idle`)}` } else { - app.headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${String(total)} selected${branchNote} ${dim( + app.headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${String(total)} selected${tabNote}${branchNote} ${dim( `sort: ${app.sortLabels[app.sortMode]} │ ${app.projects.length} projects` )}` } @@ -291,14 +294,14 @@ function renderRowContent(i: number) { let content: ReturnType let rowHeight = 1 if (row.type === "project") { - content = fmtProjectRow(project, app.selectedProjects.has(project.path)) + content = fmtProjectRow(project, app.selectedProjects.get(project.path)) } else if (row.type === "session") { content = fmtSessionRow(row.projectIndex, row.sessionIndex!, app.selectedSessions.has(project.sessions![row.sessionIndex!].id), false) rowHeight = 3 } else if (row.type === "branch") { content = fmtBranchRow(row.projectIndex, row.branchName!, app.selectedBranches.get(project.path) === row.branchName) } else { - content = fmtNewSessionRow(row.projectIndex, app.selectedProjects.has(project.path)) + content = fmtNewSessionRow(row.projectIndex, app.selectedProjects.get(project.path)) } const isCursor = i === app.cursor From c722112a7f967c1e3559483add723e46885075db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 28 Feb 2026 12:52:35 +0000 Subject: [PATCH 08/13] feat: weighted grid layout, pane list bar, sorted tabs, and idle sound delay - Replace 70/30 rearranging split with weighted grid: focused pane's column/row get 70%, others shrink in place (no spatial reordering) - Add clickable pane list row below tab bar in both grid and picker modes - Sort grid tabs numerically by tab number - Click on focused pane no longer unfocuses it - Delay idle notification sound by 10s to filter false intermediate alerts - Bump chrome top from 3 to 4 rows (tab bar, pane list, header, content) Co-Authored-By: Claude Opus 4.6 --- src/actions/launch.ts | 9 +- src/components/direct-grid.ts | 170 +++++++++++++++++++++------------- src/data/monitor.ts | 22 ++++- src/grid/view-switch.ts | 5 + src/index.ts | 7 ++ src/input/handlers.ts | 10 +- src/lib/state.ts | 1 + src/ui/panels.ts | 39 ++++++++ 8 files changed, 194 insertions(+), 69 deletions(-) diff --git a/src/actions/launch.ts b/src/actions/launch.ts index db87844..be05019 100644 --- a/src/actions/launch.ts +++ b/src/actions/launch.ts @@ -65,7 +65,14 @@ export async function doLaunch() { targetTabId = createNewGridTab() // Rename to match the picker tab number const tab = app.gridTabs.find(t => t.id === targetTabId) - if (tab) tab.name = `Tab ${tabNum}` + if (tab) { + tab.name = `Tab ${tabNum}` + app.gridTabs.sort((a, b) => { + const na = parseInt(a.name.replace(/\D/g, "")) || 0 + const nb = parseInt(b.name.replace(/\D/g, "")) || 0 + return na - nb + }) + } } const termW = process.stdout.columns || 120 diff --git a/src/components/direct-grid.ts b/src/components/direct-grid.ts index 9125858..340d0d4 100644 --- a/src/components/direct-grid.ts +++ b/src/components/direct-grid.ts @@ -85,6 +85,8 @@ export class DirectGridRenderer { // Tab bar hit-test regions (col ranges for each tab) private tabBarHitRegions: { tabId: number, startCol: number, endCol: number }[] = [] private tabBarAddBtnCol = -1 + // Pane list hit-test regions (row 2) + private paneListHitRegions: { tabId: number, paneIndex: number, startCol: number, endCol: number }[] = [] constructor(rawWrite: (s: string) => boolean) { this.writeRaw = rawWrite @@ -279,6 +281,10 @@ export class DirectGridRenderer { return this.tabPanes.get(tabId)?.length ?? 0 } + getTabPanes(tabId: number): readonly GridPaneInfo[] { + return this.tabPanes.get(tabId) ?? [] + } + hasIdleInTab(tabId: number): boolean { const panes = this.tabPanes.get(tabId) if (!panes) return false @@ -286,7 +292,7 @@ export class DirectGridRenderer { } // Check if a click hit a button on the top border. Returns action + pane index. - checkButtonClick(col: number, row: number): { action: "max" | "min" | "sel" | "tab" | "newtab", paneIndex: number, tabId?: number } | null { + checkButtonClick(col: number, row: number): { action: "max" | "min" | "sel" | "tab" | "newtab" | "panefocus", paneIndex: number, tabId?: number } | null { // Tab bar check (row 1) if (row === 1) { for (const region of this.tabBarHitRegions) { @@ -299,6 +305,15 @@ export class DirectGridRenderer { } return null } + // Pane list check (row 2) + if (row === 2) { + for (const region of this.paneListHitRegions) { + if (col >= region.startCol && col <= region.endCol) { + return { action: "panefocus", paneIndex: region.paneIndex, tabId: region.tabId } + } + } + return null + } const indicesToCheck = this.isExpanded ? [this._expandedIndex] : this.panes.map((_, i) => i) for (const i of indicesToCheck) { @@ -410,35 +425,7 @@ export class DirectGridRenderer { } focusByDirection(dir: "up" | "down" | "left" | "right") { - if (this.isSoftExpanded) { - // In soft expand: left/right toggles between expanded and strips - const sei = this._softExpandIndex - const strips = this.panes.map((_, i) => i).filter(i => i !== sei) - if (strips.length === 0) return - - if (dir === "left" || dir === "right") { - if (dir === "right" && this._focusIndex === sei) { - this.setFocus(strips[0]!) - } else if (dir === "left" && this._focusIndex !== sei) { - this.setFocus(sei) - } else { - const curStripIdx = strips.indexOf(this._focusIndex) - const nextIdx = (curStripIdx + 1) % strips.length - this.setFocus(strips[nextIdx]!) - } - return - } - // Up/down navigates within strips - if (this._focusIndex === sei) { - this.setFocus(strips[0]!) - return - } - const curStripIdx = strips.indexOf(this._focusIndex) - if (dir === "down") this.setFocus(strips[(curStripIdx + 1) % strips.length]!) - else this.setFocus(strips[(curStripIdx - 1 + strips.length) % strips.length]!) - return - } - + // Weighted grid keeps same positions — use standard grid nav for both modes const n = this.panes.length if (n <= 1) return const { cols } = this.calcGrid(n) @@ -477,12 +464,13 @@ export class DirectGridRenderer { if (n === 0) return false const termW = process.stdout.columns || 120 const termH = process.stdout.rows || 40 + const chromeTop = 4 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 cellH = Math.floor((termH - chromeTop - 1) / rows) const gc = Math.floor((col - 1) / cellW) - const gr = Math.floor((row - 2) / cellH) + const gr = Math.floor((row - chromeTop) / cellH) const idx = gr * cols + gc if (idx >= 0 && idx < n) { this.setFocus(idx) @@ -508,12 +496,13 @@ export class DirectGridRenderer { if (n === 0) return -1 const termW = process.stdout.columns || 120 const termH = process.stdout.rows || 40 + const chromeTop = 4 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 cellH = Math.floor((termH - chromeTop - 1) / rows) const gc = Math.floor((col - 1) / cellW) - const gr = Math.floor((row - 2) / cellH) + const gr = Math.floor((row - chromeTop) / cellH) const idx = gr * cols + gc return (idx >= 0 && idx < n) ? idx : -1 } @@ -530,7 +519,10 @@ export class DirectGridRenderer { // Tab bar (row 1) out += this.drawTabBar(termW) - // Header (row 2) + // Pane list (row 2) + out += this.drawPaneList(termW) + + // Header (row 3) const n = this.panes.length const fi = this._focusIndex + 1 let headerLeft: string, headerRight: string @@ -547,7 +539,7 @@ export class DirectGridRenderer { headerLeft = ` ${BOLD}cladm grid${RESET} — ${n} sessions │ focus: ${fi}/${n}` headerRight = `${DIM}shift+arrows nav │ click ${BOLD}[MAX]${RESET}${DIM} expand │ ctrl+space picker │ ctrl+w close${RESET}` } - out += `\x1b[2;1H\x1b[${termW}X${headerLeft} ${headerRight}` + out += `\x1b[3;1H\x1b[${termW}X${headerLeft} ${headerRight}` // Pane borders + titles if (this.isExpanded) { @@ -623,6 +615,45 @@ export class DirectGridRenderer { return out } + private drawPaneList(termW: number): string { + this.paneListHitRegions = [] + let out = `\x1b[2;1H\x1b[${termW}X ` + let col = 3 + + // Show panes across all tabs, grouped by tab + for (const tab of app.gridTabs) { + const tabPanes = this.tabPanes.get(tab.id) ?? [] + if (tabPanes.length === 0) continue + + for (let pi = 0; pi < tabPanes.length; pi++) { + const pane = tabPanes[pi]! + const isFocused = this._activeTabId === tab.id && this._focusIndex === pi + const name = pane.session.projectName + const short = name.length > 14 ? name.slice(0, 12) + "…" : name + const color = getColor(pane.session.colorIndex) + + const startCol = col + if (isFocused) { + out += `${hexFg(color)}${BOLD}${short}${RESET}` + } else { + out += `${DIM}${short}${RESET}` + } + col += short.length + this.paneListHitRegions.push({ tabId: tab.id, paneIndex: pi, startCol, endCol: col - 1 }) + + if (pi < tabPanes.length - 1) { + out += `${DIM} · ${RESET}` + col += 3 + } + } + + out += `${DIM} │ ${RESET}` + col += 5 + } + + return out + } + private drawPaneBorder(index: number): string { const pane = this.panes[index]! const dp = pane.directPane @@ -786,7 +817,7 @@ export class DirectGridRenderer { const n = count ?? this.panes.length const termW = process.stdout.columns || 120 const termH = process.stdout.rows || 40 - const chromeTop = 3 // row 1 = tab bar, row 2 = header, content starts row 3 + const chromeTop = 4 // row 1 = tab bar, row 2 = pane list, row 3 = header, content starts row 4 const { cols, rows } = this.calcGrid(n) const cellW = Math.floor(termW / cols) const cellH = Math.floor((termH - chromeTop - 1) / rows) // -1 for footer @@ -812,7 +843,7 @@ export class DirectGridRenderer { repositionAll() { const termW = process.stdout.columns || 120 const termH = process.stdout.rows || 40 - const chromeTop = 3 + const chromeTop = 4 if (this.isExpanded) { // Fullscreen: expanded pane gets all space @@ -824,38 +855,49 @@ export class DirectGridRenderer { resizeCapture(pane.session.name, Math.max(contentW, 10), Math.max(contentH, 2)) resetHash(`dp_${pane.session.name}`) } else if (this.isSoftExpanded) { - // Soft expand: 70/30 split + // Weighted grid: focused pane's col/row get 70%, others split the rest const sei = this._softExpandIndex const n = this.panes.length - const availH = termH - chromeTop - 1 // available rows for content + const { cols, rows } = this.calcGrid(n) + const focusCol = sei % cols + const focusRow = Math.floor(sei / cols) const availW = termW + const availH = termH - chromeTop - 1 - // Expanded pane: 70% width, full height - const expandedW = Math.max(Math.floor(availW * 0.7) - 2, 20) - const expandedH = Math.max(availH - 4, 2) - const expandedPane = this.panes[sei]! - expandedPane.directPane.reposition(2, chromeTop + 3, expandedW, expandedH) - resizeSession(expandedPane.session.name, expandedW, expandedH) - resizeCapture(expandedPane.session.name, expandedW, expandedH) - resetHash(`dp_${expandedPane.session.name}`) + // Compute column widths: focused col gets 70%, others split 30% + const colWidths: number[] = [] + const otherCols = cols - 1 + const focusColW = otherCols > 0 ? Math.floor(availW * 0.7) : availW + const otherColW = otherCols > 0 ? Math.floor((availW - focusColW) / otherCols) : 0 + for (let c = 0; c < cols; c++) colWidths.push(c === focusCol ? focusColW : otherColW) - // Strip panes: 30% width, stacked vertically - const strips = this.panes.map((_, i) => i).filter(i => i !== sei) - if (strips.length > 0) { - const stripX = 2 + expandedW + 2 // after expanded pane + border - const stripW = Math.max(availW - expandedW - 4, 10) // remaining width minus borders - const stripCellH = Math.floor(availH / strips.length) + // Compute row heights: focused row gets 70%, others split 30% + const rowHeights: number[] = [] + const otherRows = rows - 1 + const focusRowH = otherRows > 0 ? Math.floor(availH * 0.7) : availH + const otherRowH = otherRows > 0 ? Math.floor((availH - focusRowH) / otherRows) : 0 + for (let r = 0; r < rows; r++) rowHeights.push(r === focusRow ? focusRowH : otherRowH) - for (let si = 0; si < strips.length; si++) { - const pi = strips[si]! - const pane = this.panes[pi]! - const stripH = Math.max(stripCellH - 4, 2) - const stripY = chromeTop + si * stripCellH + 3 - pane.directPane.reposition(stripX, stripY, stripW, stripH) - resizeSession(pane.session.name, stripW, stripH) - resizeCapture(pane.session.name, stripW, stripH) - resetHash(`dp_${pane.session.name}`) - } + // Compute column X offsets + const colX: number[] = [0] + for (let c = 1; c < cols; c++) colX.push(colX[c - 1]! + colWidths[c - 1]!) + + // Compute row Y offsets + const rowY: number[] = [0] + for (let r = 1; r < rows; r++) rowY.push(rowY[r - 1]! + rowHeights[r - 1]!) + + for (let i = 0; i < n; i++) { + const gc = i % cols + const gr = Math.floor(i / cols) + const contentW = Math.max(colWidths[gc]! - 2, 10) + const contentH = Math.max(rowHeights[gr]! - 4, 2) + const screenX = colX[gc]! + 2 + const screenY = chromeTop + rowY[gr]! + 3 + const pane = this.panes[i]! + pane.directPane.reposition(screenX, screenY, contentW, contentH) + resizeSession(pane.session.name, contentW, contentH) + resizeCapture(pane.session.name, contentW, contentH) + resetHash(`dp_${pane.session.name}`) } } else { // Equal grid diff --git a/src/data/monitor.ts b/src/data/monitor.ts index 3facdfb..f9c6a67 100644 --- a/src/data/monitor.ts +++ b/src/data/monitor.ts @@ -312,15 +312,33 @@ export function updateProjectSessions(projects: Project[], sessions: Map() // path → timestamp when first went idle + export function checkTransitions( projects: Project[], prevBusy: Map ): string[] { + const now = Date.now() const transitioned: string[] = [] for (const project of projects) { const prev = prevBusy.get(project.path) || 0 - if (prev > 0 && project.busySessions === 0 && project.activeSessions > 0) { - transitioned.push(project.name) + const isIdle = project.busySessions === 0 && project.activeSessions > 0 + + if (prev > 0 && isIdle && !pendingIdle.has(project.path)) { + // Just transitioned busy→idle — start the delay timer + pendingIdle.set(project.path, now) + } + + if (pendingIdle.has(project.path)) { + if (!isIdle) { + // Went busy again — false alarm, cancel + pendingIdle.delete(project.path) + } else if (now - pendingIdle.get(project.path)! >= IDLE_SOUND_DELAY_MS) { + // Confirmed idle for 10+ seconds + transitioned.push(project.name) + pendingIdle.delete(project.path) + } } } return transitioned diff --git a/src/grid/view-switch.ts b/src/grid/view-switch.ts index 291dee9..2193b92 100644 --- a/src/grid/view-switch.ts +++ b/src/grid/view-switch.ts @@ -48,6 +48,11 @@ export function createNewGridTab(): number { const tabId = app.nextTabId++ const tab = { id: tabId, name: `Tab ${tabId}` } app.gridTabs.push(tab) + app.gridTabs.sort((a, b) => { + const na = parseInt(a.name.replace(/\D/g, "")) || 0 + const nb = parseInt(b.name.replace(/\D/g, "")) || 0 + return na - nb + }) if (!app.directGrid) { app.directGrid = new DirectGridRenderer(app.rawStdoutWrite) diff --git a/src/index.ts b/src/index.ts index 09bd231..cff277f 100755 --- a/src/index.ts +++ b/src/index.ts @@ -87,6 +87,12 @@ async function main() { flexShrink: 0, }) + app.paneListText = new TextRenderable(app.renderer, { + width: "100%", + height: 1, + flexShrink: 0, + }) + app.headerText = new TextRenderable(app.renderer, { width: "100%", height: 1, @@ -155,6 +161,7 @@ async function main() { }) app.mainBox.add(app.tabBarText) + app.mainBox.add(app.paneListText) app.mainBox.add(app.headerText) app.mainBox.add(app.colHeaderText) app.mainBox.add(app.listBox) diff --git a/src/input/handlers.ts b/src/input/handlers.ts index e058593..35ef085 100644 --- a/src/input/handlers.ts +++ b/src/input/handlers.ts @@ -533,12 +533,18 @@ function processGridInput(str: string) { } } else if (btn?.action === "newtab") createNewGridTab() + else if (btn?.action === "panefocus" && btn.tabId !== undefined) { + // Click on pane name in pane list → switch to that tab and focus the pane + switchToGridTab(btn.tabId) + dg.setFocus(btn.paneIndex) + if (app.clickExpand) dg.softExpandPane(btn.paneIndex) + } else { // Pane body click if (app.clickExpand && !dg.isExpanded) { const clickedIdx = dg.getPaneIndexAtClick(me.col, me.row) - if (clickedIdx >= 0) { - dg.toggleSoftExpand(clickedIdx) + if (clickedIdx >= 0 && clickedIdx !== dg.focusIndex) { + dg.softExpandPane(clickedIdx) } } else { dg.focusByClick(me.col, me.row) diff --git a/src/lib/state.ts b/src/lib/state.ts index 65f2fb8..eee9487 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -52,6 +52,7 @@ export const app = { renderer: null as unknown as CliRenderer, headerText: null as unknown as TextRenderable, tabBarText: null as unknown as TextRenderable, + paneListText: null as unknown as TextRenderable, colHeaderText: null as unknown as TextRenderable, listBox: null as unknown as ScrollBoxRenderable, bottomRow: null as unknown as BoxRenderable, diff --git a/src/ui/panels.ts b/src/ui/panels.ts index fd9ce28..ce2218c 100644 --- a/src/ui/panels.ts +++ b/src/ui/panels.ts @@ -69,6 +69,44 @@ export function applySortMode() { // ─── Tab bar ───────────────────────────────────────────────────────── +const PANE_COLORS = [ + "#7aa2f7", "#9ece6a", "#e0af68", "#f7768e", "#bb9af7", + "#7dcfff", "#ff9e64", "#c0caf5", "#73daca", "#b4f9f8", +] + +export function updatePaneList() { + if (!app.paneListText) return + if (!app.directGrid || app.gridTabs.length === 0) { + app.paneListText.content = "" + return + } + + let content = t` ` + let first = true + for (const tab of app.gridTabs) { + const tabPanes = app.directGrid.getTabPanes(tab.id) + if (tabPanes.length === 0) continue + + for (let pi = 0; pi < tabPanes.length; pi++) { + const pane = tabPanes[pi]! + const name = pane.session.projectName + const short = name.length > 14 ? name.slice(0, 12) + "…" : name + const isFocused = app.directGrid!.activeTabId === tab.id && app.directGrid!.focusIndex === pi + + if (!first) content = t`${content}${dim(" · ")}` + if (isFocused) { + content = t`${content}${bold(short)}` + } else { + content = t`${content}${dim(short)}` + } + first = false + } + content = t`${content}${dim(" │ ")}` + first = true + } + app.paneListText.content = content +} + export function updateTabBar() { if (!app.tabBarText) return @@ -363,6 +401,7 @@ function clearChildren(box: { getChildren(): { id: string }[]; remove(id: string export function updateAll() { if (app.destroyed) return updateTabBar() + updatePaneList() updateHeader() rebuildList() updateBottomPanel() From 1e105cd950390f30aba3823180b01f6e91cb1860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 28 Feb 2026 15:29:44 +0000 Subject: [PATCH 09/13] fix: buffer split mouse sequences, widen grid buttons, fix [object Object] in tab bar - Buffer partial escape sequences in stdin handler so split SGR mouse events don't leak garbage characters into PTY panes - Widen pane button hit areas from 1 char to 2-4 chars each; add title row click-to-expand; widen tab close/add buttons and pane list targets - Fix [object Object] rendering in picker tab bar and pane list caused by OpenTUI's t`` tag not handling StyledText interpolation; add st() helper that concatenates StyledText by merging chunk arrays Co-Authored-By: Claude Opus 4.6 --- src/components/direct-grid.ts | 212 ++++++++++++++++++++++++-------- src/data/session-store.ts | 119 ++++++++++++++++++ src/index.ts | 9 ++ src/input/handlers.ts | 221 +++++++++++++++++++++++++++++++--- src/lib/state.ts | 4 +- src/lib/styled.ts | 20 +++ src/lib/time.ts | 9 ++ src/lib/types.ts | 21 ++++ src/ui/panels.ts | 68 +++++++---- 9 files changed, 591 insertions(+), 92 deletions(-) create mode 100644 src/data/session-store.ts create mode 100644 src/lib/styled.ts diff --git a/src/components/direct-grid.ts b/src/components/direct-grid.ts index 340d0d4..88ee3f0 100644 --- a/src/components/direct-grid.ts +++ b/src/components/direct-grid.ts @@ -84,10 +84,15 @@ export class DirectGridRenderer { // Tab bar hit-test regions (col ranges for each tab) private tabBarHitRegions: { tabId: number, startCol: number, endCol: number }[] = [] + private tabCloseHitRegions: { tabId: number, startCol: number, endCol: number }[] = [] private tabBarAddBtnCol = -1 // Pane list hit-test regions (row 2) private paneListHitRegions: { tabId: number, paneIndex: number, startCol: number, endCol: number }[] = [] + // Pending close state + private _pendingCloseTabId = -1 + private _pendingCloseTimer: ReturnType | null = null + constructor(rawWrite: (s: string) => boolean) { this.writeRaw = rawWrite } @@ -164,6 +169,7 @@ export class DirectGridRenderer { } } this.repositionAll() + setTimeout(() => this.forceRedrawAll(), 100) this.titleTimer = setInterval(() => this.refreshTitles(), 1000) } @@ -253,6 +259,48 @@ export class DirectGridRenderer { this.tabSoftExpand.delete(tabId) } + // ─── Tab close (double-click confirm) ──────────────── + + get pendingCloseTabId() { return this._pendingCloseTabId } + + requestCloseTab(tabId: number): "pending" | "closed" { + if (this._pendingCloseTabId === tabId) { + // Second click — execute close + this.cancelPendingClose() + this.closeTab(tabId) + return "closed" + } + // First click — mark pending + this.cancelPendingClose() + this._pendingCloseTabId = tabId + this._pendingCloseTimer = setTimeout(() => { + this._pendingCloseTabId = -1 + this._pendingCloseTimer = null + this.drawChrome() + }, 2000) + this.drawChrome() + return "pending" + } + + closeTab(tabId: number): number { + const tabIdx = app.gridTabs.findIndex(t => t.id === tabId) + if (tabIdx < 0) return -1 + this.removeTab(tabId) + app.gridTabs.splice(tabIdx, 1) + return tabIdx + } + + cancelPendingClose() { + if (this._pendingCloseTimer) { + clearTimeout(this._pendingCloseTimer) + this._pendingCloseTimer = null + } + if (this._pendingCloseTabId !== -1) { + this._pendingCloseTabId = -1 + this.drawChrome() + } + } + setActiveTab(tabId: number) { if (this._activeTabId === tabId) return // Detach current tab's panes @@ -274,6 +322,7 @@ export class DirectGridRenderer { if (this.running) { this.writeRaw(CLEAR) this.repositionAll() + setTimeout(() => this.forceRedrawAll(), 100) } } @@ -292,23 +341,31 @@ export class DirectGridRenderer { } // Check if a click hit a button on the top border. Returns action + pane index. - checkButtonClick(col: number, row: number): { action: "max" | "min" | "sel" | "tab" | "newtab" | "panefocus", paneIndex: number, tabId?: number } | null { + // Hit areas are widened beyond the visible dot characters to make clicking easier. + checkButtonClick(col: number, row: number): { action: "max" | "min" | "sel" | "tab" | "newtab" | "panefocus" | "closetab" | "closepane", paneIndex: number, tabId?: number } | null { // Tab bar check (row 1) if (row === 1) { + // Check close buttons first — widened ±1 around the × character + for (const region of this.tabCloseHitRegions) { + if (col >= region.startCol - 1 && col <= region.endCol + 1) { + return { action: "closetab", paneIndex: -1, tabId: region.tabId } + } + } for (const region of this.tabBarHitRegions) { if (col >= region.startCol && col <= region.endCol) { return { action: "tab", paneIndex: -1, tabId: region.tabId } } } - if (this.tabBarAddBtnCol > 0 && col >= this.tabBarAddBtnCol && col <= this.tabBarAddBtnCol + 2) { + // [+] button — widened ±1 + if (this.tabBarAddBtnCol > 0 && col >= this.tabBarAddBtnCol - 1 && col <= this.tabBarAddBtnCol + 3) { return { action: "newtab", paneIndex: -1 } } return null } - // Pane list check (row 2) + // Pane list check (row 2) — widened ±1 for easier clicks if (row === 2) { for (const region of this.paneListHitRegions) { - if (col >= region.startCol && col <= region.endCol) { + if (col >= region.startCol - 1 && col <= region.endCol + 1) { return { action: "panefocus", paneIndex: region.paneIndex, tabId: region.tabId } } } @@ -321,21 +378,30 @@ export class DirectGridRenderer { const bx = dp.screenX - 1 const by = dp.screenY - 3 const bw = dp.width + 2 - const btnRow = by - if (row !== btnRow) continue + // Top border row — traffic light buttons with widened hit areas + if (row === by) { + if (this.isExpanded) { + // Layout: ...─● ● ●─╮ (close=bw-7, min=bw-5, sel=bw-3) + // sel (rightmost): dot + border + corner = 3 chars + if (col >= bx + bw - 4 && col <= bx + bw - 1) return { action: "sel", paneIndex: i } + // min: dot ± 1 = 3 chars + if (col >= bx + bw - 6 && col <= bx + bw - 4) return { action: "min", paneIndex: i } + // close: border + dot = 2 chars (smaller to avoid accidents) + if (col >= bx + bw - 8 && col <= bx + bw - 6) return { action: "closepane", paneIndex: i } + } else { + // Layout: ...─● ●─╮ (close=bw-5, max=bw-3) + // max (rightmost): space + dot + border + corner = 4 chars + if (col >= bx + bw - 4 && col <= bx + bw - 1) return { action: "max", paneIndex: i } + // close: border + dot + space = 3 chars + if (col >= bx + bw - 7 && col <= bx + bw - 5) return { action: "closepane", paneIndex: i } + } + continue + } - if (this.isExpanded) { - 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 { - const btnLeft = bx + bw - 7 - const btnRight = bx + bw - 3 - if (col >= btnLeft && col <= btnRight) return { action: "max", paneIndex: i } + // Title row (by+1) — click to expand/focus + if (row === by + 1 && !this.isExpanded) { + return { action: "max", paneIndex: i } } } return null @@ -374,6 +440,10 @@ export class DirectGridRenderer { this.drawPane(idx, lines) } this.repositionAll() + + // Force-redraw all panes after a short delay to catch initial frames + // that may have arrived before attach or been cleared by repositionAll + setTimeout(() => this.forceRedrawAll(), 200) } return info @@ -531,13 +601,13 @@ export class DirectGridRenderer { 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+space picker${RESET}` + headerRight = `${DIM}${hexFg("#f7768e")}●${RESET}${DIM} close │ ${hexFg("#e0af68")}●${RESET}${DIM} restore │ ${hexFg("#9ece6a")}●${RESET}${DIM} select │ ctrl+space picker${RESET}` } else if (this.isSoftExpanded) { headerLeft = ` ${BOLD}cladm grid${RESET} — ${hexFg("#bb9af7")}${BOLD}FOCUS${RESET} │ ${fi}/${n}` - headerRight = `${DIM}click pane to focus │ click ${BOLD}[MAX]${RESET}${DIM} fullscreen │ ctrl+e toggle${RESET}` + headerRight = `${DIM}click pane to focus │ ${hexFg("#9ece6a")}●${RESET}${DIM} fullscreen │ ctrl+e toggle${RESET}` } else { headerLeft = ` ${BOLD}cladm grid${RESET} — ${n} sessions │ focus: ${fi}/${n}` - headerRight = `${DIM}shift+arrows nav │ click ${BOLD}[MAX]${RESET}${DIM} expand │ ctrl+space picker │ ctrl+w close${RESET}` + headerRight = `${DIM}shift+arrows nav │ ${hexFg("#f7768e")}●${RESET}${DIM} close ${hexFg("#9ece6a")}●${RESET}${DIM} expand │ ctrl+space picker${RESET}` } out += `\x1b[3;1H\x1b[${termW}X${headerLeft} ${headerRight}` @@ -556,7 +626,7 @@ export class DirectGridRenderer { out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg("#9ece6a")}${BOLD}SELECT MODE${RESET} ${DIM}drag to select text │ cmd+c to copy │ press ${BOLD}Esc${RESET}${DIM} to exit${RESET}` } 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}` + out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg(color)}▸${RESET} ${BOLD}${pane.session.projectName}${RESET} ${DIM}expanded │ Esc or ${hexFg("#e0af68")}●${RESET}${DIM} to restore grid${RESET}` } else if (pane) { const color = getColor(pane.session.colorIndex) const sid = pane.session.sessionId ? ` ${DIM}#${pane.session.sessionId.slice(0, 8)}${RESET}` : "" @@ -572,17 +642,27 @@ export class DirectGridRenderer { private drawTabBar(termW: number): string { this.tabBarHitRegions = [] + this.tabCloseHitRegions = [] this.tabBarAddBtnCol = -1 - let out = `\x1b[1;1H\x1b[${termW}X ` - let col = 2 + const RED_FG = hexFg("#f7768e") + const TAB_BG_ACTIVE = hexBg("#24283b") + const TAB_BORDER = hexFg("#3b4261") + + let out = `\x1b[1;1H\x1b[${termW}X` + let col = 1 // Picker tab (id = -1, meaning: switch to picker) const pickerActive = app.viewMode === "picker" - const pickerLabel = pickerActive ? `${CYAN_FG}${BOLD}● Picker${RESET}` : `${DIM}○ Picker${RESET}` - out += pickerLabel + ` ${DIM}│${RESET} ` - this.tabBarHitRegions.push({ tabId: -1, startCol: col, endCol: col + 7 }) - col += 11 // "● Picker │ " + if (pickerActive) { + out += `${TAB_BORDER}╭${RESET}${TAB_BG_ACTIVE} ${CYAN_FG}${BOLD}● Picker${RESET}${TAB_BG_ACTIVE} ${RESET}${TAB_BORDER}╮${RESET}` + } else { + out += ` ${DIM}○ Picker${RESET} ` + } + const pickerStart = pickerActive ? col + 1 : col + 1 + const pickerVisLen = pickerActive ? 10 : 10 + this.tabBarHitRegions.push({ tabId: -1, startCol: pickerStart, endCol: pickerStart + 7 }) + col += pickerVisLen // Grid tabs for (const tab of app.gridTabs) { @@ -590,25 +670,45 @@ export class DirectGridRenderer { const hasIdle = this.hasIdleInTab(tab.id) const count = this.getTabPaneCount(tab.id) const label = `${tab.name} (${count})` + const isPending = this._pendingCloseTabId === tab.id - let tabText: string - if (isActive) { - tabText = `${CYAN_FG}${BOLD}● ${label}${RESET}` - } else if (hasIdle) { - tabText = `${YELLOW_FG}◉ ${label}${RESET}` - } else { - tabText = `${DIM}○ ${label}${RESET}` - } - - const startCol = col - out += tabText + ` ${DIM}│${RESET} ` + const startCol = col + (isActive ? 2 : 1) // account for ╭ + space or just space const visLen = 2 + label.length // "● " + label - this.tabBarHitRegions.push({ tabId: tab.id, startCol, endCol: startCol + visLen - 1 }) - col += visLen + 3 // + " │ " + + // Close button text + const closeText = isPending ? `${RED_FG}${BOLD}●${RESET}` : `${DIM}×${RESET}` + const closeVisLen = 1 + + if (isActive) { + // Chrome-style raised active tab + out += `${TAB_BORDER}╭${RESET}${TAB_BG_ACTIVE} ${CYAN_FG}${BOLD}● ${label}${RESET}${TAB_BG_ACTIVE} ${closeText}${TAB_BG_ACTIVE} ${RESET}${TAB_BORDER}╮${RESET}` + // ╭ + space + ● label + space + × + space + ╮ + const totalVis = 1 + 1 + visLen + 1 + closeVisLen + 1 + 1 + this.tabBarHitRegions.push({ tabId: tab.id, startCol, endCol: startCol + visLen - 1 }) + const closeStartCol = startCol + visLen + 1 + this.tabCloseHitRegions.push({ tabId: tab.id, startCol: closeStartCol, endCol: closeStartCol + closeVisLen - 1 }) + col += totalVis + } else { + // Inactive tab — flat, no border + let indicator: string + if (hasIdle) { + indicator = `${YELLOW_FG}◉ ${label}${RESET}` + } else { + indicator = `${DIM}○ ${label}${RESET}` + } + out += ` ${indicator} ${closeText} ${DIM}│${RESET}` + // space + ● label + space + × + space + │ + const totalVis = 1 + visLen + 1 + closeVisLen + 1 + 1 + this.tabBarHitRegions.push({ tabId: tab.id, startCol, endCol: startCol + visLen - 1 }) + const closeStartCol = startCol + visLen + 1 + this.tabCloseHitRegions.push({ tabId: tab.id, startCol: closeStartCol, endCol: closeStartCol + closeVisLen - 1 }) + col += totalVis + } } // [+] button - out += `${DIM}[+]${RESET}` + out += ` ${DIM}[+]${RESET}` + col += 1 this.tabBarAddBtnCol = col col += 3 @@ -682,16 +782,23 @@ export class DirectGridRenderer { let out = "" - // Top border with buttons + // Top border with traffic-light buttons (macOS style: close, minimize, expand) + const RED_DOT = `${hexFg("#f7768e")}●${RESET}` // close pane + const YELLOW_DOT = `${hexFg("#e0af68")}●${RESET}` // minimize / collapse + const GREEN_DOT = `${hexFg("#9ece6a")}●${RESET}` // expand / maximize + const DIM_DOT = `${DIM}●${RESET}` + let btnSection: string let btnVisibleLen: number if (this.isExpanded) { - 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 + // Expanded: show close · minimize · select(green means select mode) + const selDot = this._selectMode ? `${hexFg("#9ece6a")}${BOLD}●${RESET}` : DIM_DOT + btnSection = `${borderColor}${hz}${RESET}${RED_DOT} ${YELLOW_DOT} ${selDot}${borderColor}` + btnVisibleLen = 1 + 1 + 1 + 1 + 1 + 1 + 1 // ─● ● ● } else { - btnSection = `${RESET}${DIM}[MAX]${RESET}${borderColor}` - btnVisibleLen = 5 + // Grid: show close · expand + btnSection = `${borderColor}${hz}${RESET}${RED_DOT} ${GREEN_DOT}${borderColor}` + btnVisibleLen = 1 + 1 + 1 + 1 // ─● ● } const hzFill = Math.max(0, bw - 2 - btnVisibleLen - 1) out += `\x1b[${by};${bx}H${borderColor}${tl}${hz.repeat(hzFill)}${btnSection}${hz}${tr}${RESET}` @@ -740,6 +847,17 @@ export class DirectGridRenderer { this.writeRaw(SYNC_START + frame + SYNC_END) } + forceRedrawAll() { + if (!this.running) return + for (let i = 0; i < this.panes.length; i++) { + const pane = this.panes[i]! + resetHash(`dp_${pane.session.name}`) + const frame = getLatestFrame(pane.session.name) + if (frame) this.drawPane(i, frame.lines) + } + this.drawChrome() + } + // ─── Input ───────────────────────────────────────────── sendInputToFocused(rawSequence: string) { diff --git a/src/data/session-store.ts b/src/data/session-store.ts new file mode 100644 index 0000000..da35b65 --- /dev/null +++ b/src/data/session-store.ts @@ -0,0 +1,119 @@ +import { existsSync, mkdirSync, writeFileSync, unlinkSync } from "fs" +import { join, dirname } from "path" +import { app } from "../lib/state" +import type { SavedSession, SavedTab, SavedPane } from "../lib/types" +import { createSession } from "../pty/session-manager" +import { ensureGridView, switchToGridTab } from "../grid/view-switch" + +const SESSION_PATH = join(process.env.HOME ?? "", ".config", "cladm", "session.json") + +export function extractSessionState(): SavedSession | null { + const dg = app.directGrid + if (!dg || app.gridTabs.length === 0) return null + + const tabs: SavedTab[] = [] + for (const tab of app.gridTabs) { + const paneInfos = dg.getTabPanes(tab.id) + const panes: SavedPane[] = [] + for (const p of paneInfos) { + if (!p.session.alive) continue + panes.push({ + projectPath: p.session.projectPath, + projectName: p.session.projectName, + sessionId: p.session.sessionId, + targetBranch: p.session.targetBranch, + }) + } + if (panes.length > 0) { + tabs.push({ id: tab.id, name: tab.name, panes }) + } + } + + if (tabs.length === 0) return null + + const activeIdx = app.gridTabs.findIndex(t => t.id === dg.activeTabId) + return { + version: 1, + savedAt: Date.now(), + activeTabIndex: Math.max(0, activeIdx), + nextTabId: app.nextTabId, + tabs, + } +} + +export function saveSessionSync(data: SavedSession): void { + const dir = dirname(SESSION_PATH) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + writeFileSync(SESSION_PATH, JSON.stringify(data, null, 2)) +} + +export async function loadSavedSession(): Promise { + try { + const file = Bun.file(SESSION_PATH) + if (!await file.exists()) return null + const data = await file.json() as SavedSession + if (data.version !== 1 || !Array.isArray(data.tabs)) return null + return data + } catch { + return null + } +} + +export function deleteSavedSession(): void { + try { unlinkSync(SESSION_PATH) } catch {} +} + +export async function restoreSession(saved: SavedSession, useResume: boolean): Promise { + ensureGridView() + + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + + let firstTabId: number | null = null + + for (const savedTab of saved.tabs) { + const tabId = app.nextTabId++ + const tab = { id: tabId, name: savedTab.name } + app.gridTabs.push(tab) + app.directGrid!.addTab(tab) + if (firstTabId === null) firstTabId = tabId + + const validPanes = savedTab.panes.filter(p => existsSync(p.projectPath)) + const n = validPanes.length + const cols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : 3 + const rows = Math.ceil(n / cols) + const paneW = Math.max(Math.floor(termW / cols) - 2, 20) + const paneH = Math.max(Math.floor((termH - 2) / rows) - 4, 6) + + for (const pane of validPanes) { + const session = await createSession({ + projectPath: pane.projectPath, + projectName: pane.projectName, + sessionId: useResume ? pane.sessionId : undefined, + targetBranch: pane.targetBranch, + width: paneW, + height: paneH, + }) + await app.directGrid!.addPane(session, tabId) + } + } + + // Sort tabs by name + app.gridTabs.sort((a, b) => { + const na = parseInt(a.name.replace(/\D/g, "")) || 0 + const nb = parseInt(b.name.replace(/\D/g, "")) || 0 + return na - nb + }) + + // Switch to saved active tab + const targetIdx = Math.min(saved.activeTabIndex, app.gridTabs.length - 1) + if (targetIdx >= 0 && app.gridTabs[targetIdx]) { + switchToGridTab(app.gridTabs[targetIdx].id) + } else if (firstTabId !== null) { + switchToGridTab(firstTabId) + } + + deleteSavedSession() + app.savedSession = null + app.restoreMode = null +} diff --git a/src/index.ts b/src/index.ts index cff277f..1251798 100755 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ import { app } from "./lib/state" import { updateAll, rebuildDisplayRows, updateUsagePanel, updateColumnHeaders } from "./ui/panels" import { stdinHandler } from "./input/handlers" import { resizeGridPanes } from "./grid/view-switch" +import { loadSavedSession, extractSessionState, saveSessionSync } from "./data/session-store" function refreshMockSessions(projects: Project[]) { generateMockActiveSessions(projects) @@ -55,6 +56,9 @@ async function main() { app.sortedIndices = app.projects.map((_, i) => i) rebuildDisplayRows() + // Load saved session for restore hint + app.savedSession = await loadSavedSession() + // Save raw stdout.write BEFORE OpenTUI intercepts it app.rawStdoutWrite = process.stdout.write.bind(process.stdout) as (s: string) => boolean @@ -64,6 +68,11 @@ async function main() { useMouse: false, onDestroy: () => { app.destroyed = true + // Save session state before cleanup + try { + const state = extractSessionState() + if (state) saveSessionSync(state) + } catch {} if (app.monitorInterval) { clearInterval(app.monitorInterval); app.monitorInterval = null } if (app.directGrid) app.directGrid.destroyAll() stopAllCaptures() diff --git a/src/input/handlers.ts b/src/input/handlers.ts index 35ef085..65b7ea6 100644 --- a/src/input/handlers.ts +++ b/src/input/handlers.ts @@ -11,6 +11,7 @@ import { generateMockSessions, generateMockBranches } from "../data/mock" import { focusTerminalByPath, populateMockSessionStatus } from "../data/monitor" import { stopAllCaptures } from "../pty/capture" import type { DisplayRow } from "../lib/types" +import { extractSessionState, saveSessionSync, restoreSession } from "../data/session-store" // ─── Constants ─────────────────────────────────────────────────────── @@ -216,27 +217,62 @@ export function handlePickerClick(_col: number, screenRow: number) { function handlePickerTabBarClick(col: number, screenRow: number) { // Tab bar is at row 1 in picker (rendered as OpenTUI text) if (screenRow !== 1) return false - // Hit test against tab bar positions (approximate, since OpenTUI renders it) - // We compute positions similar to the grid tab bar - let c = 2 - // Picker tab - const pickerEnd = c + 7 + // Hit test against tab bar positions — Chrome-style layout + // Picker: ╭ ● Picker ╮ = cols 1..10 (active) or ○ Picker = cols 1..10 + let c = 1 + const pickerEnd = c + 10 if (col >= c && col <= pickerEnd) return false // already on picker - c += 11 + c = 11 for (const tab of app.gridTabs) { const count = app.directGrid?.getTabPaneCount(tab.id) ?? 0 + const isActive = app.viewMode === "grid" && app.directGrid?.activeTabId === tab.id const label = `${tab.name} (${count})` - const visLen = 2 + label.length - if (col >= c && col < c + visLen) { - switchToGridTab(tab.id) - return true + const visLen = 2 + label.length // "● " + label + + const dg = app.directGrid + + if (isActive) { + // Active: ╭ ● label × ╮ → c+1=╭, c+2=space, c+3..=● label, then close, ╮ + const labelStart = c + 2 + const labelEnd = labelStart + visLen - 1 + const closeCol = labelEnd + 2 // space + × position + const totalVis = 1 + 1 + visLen + 1 + 1 + 1 + 1 // ╭ + sp + label + sp + × + sp + ╮ + + if (col === closeCol && dg) { + const result = dg.requestCloseTab(tab.id) + if (result === "closed") updateAll() + else { updateTabBar(); app.renderer.requestRender() } + return true + } + if (col >= labelStart && col <= labelEnd) { + switchToGridTab(tab.id) + return true + } + c += totalVis + } else { + // Inactive: sp ● label sp × sp │ → 1 + visLen + 1 + 1 + 1 + 1 + const labelStart = c + 1 + const labelEnd = labelStart + visLen - 1 + const closeCol = labelEnd + 2 + const totalVis = 1 + visLen + 1 + 1 + 1 + 1 // sp + label + sp + × + sp + │ + + if (col === closeCol && dg) { + const result = dg.requestCloseTab(tab.id) + if (result === "closed") updateAll() + else { updateTabBar(); app.renderer.requestRender() } + return true + } + if (col >= labelStart && col <= labelEnd) { + switchToGridTab(tab.id) + return true + } + c += totalVis } - c += visLen + 3 } // [+] button - if (col >= c && col <= c + 2) { + if (col >= c + 1 && col <= c + 3) { createNewGridTab() return true } @@ -379,10 +415,48 @@ export async function handleKeypress(key: KeyEvent) { break } - case "q": + case "r": { + if (app.restoreMode === "pending") { + // Second press: restore with resume + const saved = app.savedSession + if (saved) { + app.restoreMode = null + await restoreSession(saved, true) + return + } + } else if (app.savedSession) { + app.restoreMode = "pending" + } + break + } + + case "R": { + if (app.restoreMode === "pending") { + // Shift+R: restore fresh (no sessionIds) + const saved = app.savedSession + if (saved) { + app.restoreMode = null + await restoreSession(saved, false) + return + } + } + break + } + case "escape": + if (app.restoreMode === "pending") { + app.restoreMode = null + break + } + // fall through to quit + case "q": app.destroyed = true if (app.monitorInterval) clearInterval(app.monitorInterval) + // Save session before exit + try { + const state = extractSessionState() + if (state) saveSessionSync(state) + } catch {} stopAllCaptures() process.stdout.write("\x1b[?1006l") process.stdout.write("\x1b[?1000l") @@ -520,10 +594,49 @@ function processGridInput(str: string) { if (me.btn === 65) { dg.sendScrollToFocused("down", 3); continue } if (me.btn === 0 && !me.release) { const btn = dg.checkButtonClick(me.col, me.row) - if (btn?.action === "max") dg.expandPane(btn.paneIndex) - else if (btn?.action === "min") dg.collapsePane() - else if (btn?.action === "sel") dg.enterSelectMode() + if (btn?.action === "closetab" && btn.tabId !== undefined) { + const result = dg.requestCloseTab(btn.tabId) + if (result === "closed") { + // Tab was closed — switch to adjacent or picker + if (app.gridTabs.length > 0) { + const currentTabId = dg.activeTabId + if (btn.tabId === currentTabId) { + // Closed the active tab — switch to first available + switchToGridTab(app.gridTabs[0].id) + } else { + dg.drawChrome() + } + } else { + switchToPicker() + } + } + } + else if (btn?.action === "closepane") { + dg.cancelPendingClose() + const pane = dg.paneCount > btn.paneIndex ? dg.getTabPanes(dg.activeTabId)[btn.paneIndex] : null + if (pane) { + if (dg.isExpanded) dg.collapsePane() + if (dg.isSoftExpanded) dg.softCollapsePane() + dg.removePane(pane.session.name) + if (dg.paneCount === 0) { + const currentTabId = dg.activeTabId + const tabIdx = app.gridTabs.findIndex(t => t.id === currentTabId) + dg.removeTab(currentTabId) + app.gridTabs.splice(tabIdx, 1) + if (app.gridTabs.length > 0) { + const prevIdx = Math.max(0, tabIdx - 1) + switchToGridTab(app.gridTabs[prevIdx].id) + } else { + switchToPicker() + } + } + } + } + else if (btn?.action === "max") { dg.cancelPendingClose(); dg.expandPane(btn.paneIndex) } + else if (btn?.action === "min") { dg.cancelPendingClose(); dg.collapsePane() } + else if (btn?.action === "sel") { dg.cancelPendingClose(); dg.enterSelectMode() } else if (btn?.action === "tab") { + dg.cancelPendingClose() if (btn.tabId === -1) { // Switch to picker app.lastGridTabIndex = app.gridTabs.findIndex(t => t.id === dg.activeTabId) @@ -532,14 +645,16 @@ function processGridInput(str: string) { switchToGridTab(btn.tabId) } } - else if (btn?.action === "newtab") createNewGridTab() + else if (btn?.action === "newtab") { dg.cancelPendingClose(); createNewGridTab() } else if (btn?.action === "panefocus" && btn.tabId !== undefined) { + dg.cancelPendingClose() // Click on pane name in pane list → switch to that tab and focus the pane switchToGridTab(btn.tabId) dg.setFocus(btn.paneIndex) if (app.clickExpand) dg.softExpandPane(btn.paneIndex) } else { + dg.cancelPendingClose() // Pane body click if (app.clickExpand && !dg.isExpanded) { const clickedIdx = dg.getPaneIndexAtClick(me.col, me.row) @@ -630,10 +745,76 @@ function processPickerInput(str: string) { } } -// ─── Stdin entry point ─────────────────────────────────────────────── +// ─── Stdin buffering ───────────────────────────────────────────────── +// SGR mouse sequences (\x1b[ | null = null + +function dispatch(str: string) { if (app.viewMode === "grid" && app.directGrid) processGridInput(str) else processPickerInput(str) } + +function flushPending() { + _timer = null + if (_pending) { + const p = _pending + _pending = "" + dispatch(p) + } +} + +// Returns index of a trailing partial escape sequence, or -1 if complete. +function trailingPartialEsc(data: string): number { + for (let i = data.length - 1; i >= 0 && i >= data.length - 30; i--) { + if (data.charCodeAt(i) !== 0x1b) continue + const ch = data[i + 1] + // Lone ESC at end + if (ch === undefined) return i + // CSI: \x1b[ — check for final byte + if (ch === "[") { + let j = i + 2 + while (j < data.length && data.charCodeAt(j) >= 0x30 && data.charCodeAt(j) <= 0x3f) j++ + while (j < data.length && data.charCodeAt(j) >= 0x20 && data.charCodeAt(j) <= 0x2f) j++ + if (j >= data.length) return i // no final byte yet — partial + continue + } + // OSC/DCS/APC/PM — need ST terminator + if (ch === "]" || ch === "P" || ch === "_" || ch === "^") { + let terminated = false + for (let j = i + 2; j < data.length; j++) { + if (data[j] === "\x07") { terminated = true; break } + if (data[j] === "\x1b" && data[j + 1] === "\\") { terminated = true; break } + } + if (!terminated) return i + continue + } + // SS3 (\x1bO) needs one more byte + if (ch === "O" && i + 2 >= data.length) return i + continue + } + return -1 +} + +// ─── Stdin entry point ─────────────────────────────────────────────── + +export function stdinHandler(data: string | Buffer) { + if (_timer) { clearTimeout(_timer); _timer = null } + const str = typeof data === "string" ? data : data.toString("utf8") + const full = _pending + str + _pending = "" + + const idx = trailingPartialEsc(full) + if (idx >= 0) { + _pending = full.slice(idx) + const ready = full.slice(0, idx) + if (ready) dispatch(ready) + _timer = setTimeout(flushPending, 8) + return + } + + dispatch(full) +} diff --git a/src/lib/state.ts b/src/lib/state.ts index eee9487..c13fba0 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -1,6 +1,6 @@ import type { CliRenderer } from "@opentui/core" import type { BoxRenderable, TextRenderable, ScrollBoxRenderable } from "@opentui/core" -import type { Project, DisplayRow } from "./types" +import type { Project, DisplayRow, SavedSession } from "./types" import type { DirectGridRenderer } from "../components/direct-grid" import type { UsageSummary } from "../data/usage" import type { IdleSessionInfo } from "../data/monitor" @@ -47,6 +47,8 @@ export const app = { nextTabId: 1, // auto-increment for tab ids clickExpand: true, // click-to-expand feature toggle lastGridTabIndex: 0, // last active grid tab for Ctrl+Space toggle + savedSession: null as SavedSession | null, + restoreMode: null as "pending" | null, // UI refs (set during init) renderer: null as unknown as CliRenderer, diff --git a/src/lib/styled.ts b/src/lib/styled.ts new file mode 100644 index 0000000..36f39c3 --- /dev/null +++ b/src/lib/styled.ts @@ -0,0 +1,20 @@ +import { StyledText } from "@opentui/core" + +type Chunk = { __isChunk: true; text: string; attributes: number; fg?: unknown; bg?: unknown } +type StyledPart = string | StyledText | Chunk + +// Concatenate styled text parts into a single StyledText. +// OpenTUI's t`` tag doesn't handle StyledText interpolation — it calls +// toString() which produces "[object Object]". This helper merges chunks +// from multiple t`` results, TextChunks, and plain strings. +export function st(...parts: StyledPart[]): StyledText { + const chunks: Chunk[] = [] + for (const p of parts) { + if (p instanceof StyledText) chunks.push(...p.chunks) + else if (p && typeof p === "object" && "__isChunk" in p) chunks.push(p as Chunk) + else if (typeof p === "string") { + if (p.length > 0) chunks.push({ __isChunk: true, text: p, attributes: 0 } as Chunk) + } + } + return new StyledText(chunks) +} diff --git a/src/lib/time.ts b/src/lib/time.ts index 18078b6..7a88517 100644 --- a/src/lib/time.ts +++ b/src/lib/time.ts @@ -20,6 +20,15 @@ export function elapsedCompact(ms: number): string { return `${Math.floor(sec / 86400)}d` } +export function timeAgoShort(ms: number): string { + if (!ms) return "" + const diff = Math.floor((Date.now() - ms) / 1000) + if (diff < 60) return "0m ago" + if (diff < 3600) return `${Math.floor(diff / 60)}m ago` + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago` + return `${Math.floor(diff / 86400)}d ago` +} + export function formatSize(bytes: number): string { if (bytes < 1024) return `${bytes}B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` diff --git a/src/lib/types.ts b/src/lib/types.ts index 75110f5..06b6bda 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -46,3 +46,24 @@ export interface DisplayRow { sessionIndex?: number branchName?: string } + +export interface SavedPane { + projectPath: string + projectName: string + sessionId?: string + targetBranch?: string +} + +export interface SavedTab { + id: number + name: string + panes: SavedPane[] +} + +export interface SavedSession { + version: 1 + savedAt: number + activeTabIndex: number + nextTabId: number + tabs: SavedTab[] +} diff --git a/src/ui/panels.ts b/src/ui/panels.ts index ce2218c..5e8a3cd 100644 --- a/src/ui/panels.ts +++ b/src/ui/panels.ts @@ -9,12 +9,14 @@ import { yellow, cyan, magenta, + red, } from "@opentui/core" +import { st } from "../lib/styled" import { app } from "../lib/state" import { CURSOR_BG, ACTIVE_BG, ACCENT } from "../lib/theme" import { getSessionStatus, getIdleSessions } from "../data/monitor" import { formatCost, formatWindow, makeBar, pct, PLAN_LIMITS } from "../data/usage" -import { timeAgo, formatSize, elapsedCompact } from "../lib/time" +import { timeAgo, formatSize, elapsedCompact, timeAgoShort } from "../lib/time" import { fmtProjectRow, fmtSessionRow, fmtNewSessionRow, fmtBranchRow, fmtSyncIndicator } from "./formatters" // ─── Display rows ──────────────────────────────────────────────────── @@ -81,7 +83,7 @@ export function updatePaneList() { return } - let content = t` ` + const parts: Parameters = [t` `] let first = true for (const tab of app.gridTabs) { const tabPanes = app.directGrid.getTabPanes(tab.id) @@ -93,48 +95,50 @@ export function updatePaneList() { const short = name.length > 14 ? name.slice(0, 12) + "…" : name const isFocused = app.directGrid!.activeTabId === tab.id && app.directGrid!.focusIndex === pi - if (!first) content = t`${content}${dim(" · ")}` - if (isFocused) { - content = t`${content}${bold(short)}` - } else { - content = t`${content}${dim(short)}` - } + if (!first) parts.push(dim(" · ")) + parts.push(isFocused ? bold(short) : dim(short)) first = false } - content = t`${content}${dim(" │ ")}` + parts.push(dim(" │ ")) first = true } - app.paneListText.content = content + app.paneListText.content = st(...parts) } export function updateTabBar() { if (!app.tabBarText) return - // Build tab bar segments using styled text - const sep = dim(" │ ") const pickerActive = app.viewMode === "picker" - const pickerTab = pickerActive ? t`${cyan("●")} ${bold("Picker")}` : t`${dim("○ Picker")}` + const sep = dim(" │ ") - // Start with picker - let content = t` ${pickerTab}` + // Chrome-style: active tab gets visual emphasis + const parts: Parameters = [] + if (pickerActive) { + parts.push(t` ${dim("╭")} ${cyan("●")} ${bold("Picker")} ${dim("╮")}`) + } else { + parts.push(t` ${dim("○ Picker")} `) + } // Grid tabs for (const tab of app.gridTabs) { const count = app.directGrid?.getTabPaneCount(tab.id) ?? 0 const hasIdle = app.directGrid?.hasIdleInTab(tab.id) ?? false const isActive = app.viewMode === "grid" && app.directGrid?.activeTabId === tab.id + const isPending = app.directGrid?.pendingCloseTabId === tab.id const label = `${tab.name} (${count})` + const closeBtn = isPending ? t` ${red(bold("●"))}` : t` ${dim("×")}` + if (isActive) { - content = t`${content}${sep}${cyan("●")} ${bold(label)}` + parts.push(dim("╭"), t` ${cyan("●")} ${bold(label)}`, closeBtn, t` ${dim("╮")}`) } else if (hasIdle) { - content = t`${content}${sep}${yellow("◉")} ${label}` + parts.push(t` ${yellow("◉")} ${label}`, closeBtn, " ", sep) } else { - content = t`${content}${sep}${dim("○ " + label)}` + parts.push(t` ${dim("○ " + label)}`, closeBtn, " ", sep) } } - content = t`${content}${sep}${dim("[+]")}` - app.tabBarText.content = content + parts.push(t` ${dim("[+]")}`) + app.tabBarText.content = st(...parts) } // ─── Header / Footer ───────────────────────────────────────────────── @@ -167,13 +171,28 @@ export function updateColumnHeaders() { export function updateFooter() { const gridHint = app.directGrid && app.directGrid.totalPaneCount > 0 ? " │ ^space grid" : "" + + // Restore mode: show choice prompt + if (app.restoreMode === "pending") { + app.footerText.content = t` ${yellow("Restore session?")} ${dim("r resume │ R fresh │ esc cancel")}` + return + } + + // Saved session hint + let restoreHint = "" + if (app.savedSession) { + const ago = timeAgoShort(app.savedSession.savedAt) + const paneCount = app.savedSession.tabs.reduce((sum, t) => sum + t.panes.length, 0) + restoreHint = ` │ r restore (${paneCount}p, ${ago})` + } + if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0) { app.footerText.content = t` ${dim( - "↑↓ nav │ tab/shift-tab idle select │ enter focus │ i preview │ space select │ a all │ n none │ s sort │ q quit" + gridHint + "↑↓ nav │ tab/shift-tab idle select │ enter focus │ i preview │ space select │ a all │ n none │ s sort │ q quit" + gridHint + restoreHint )}` } else { app.footerText.content = t` ${dim( - "↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter grid │ o external │ q quit" + gridHint + "↑↓ nav │ space select │ → expand │ ← collapse │ f folder │ g go to │ i idle │ a all │ n none │ s sort │ enter grid │ o external │ q quit" + gridHint + restoreHint )}` } } @@ -310,9 +329,10 @@ export function updatePreview() { const selNote = selBranch === br.name ? t` ${green("Selected")} — will launch with: ${dim(`-p "switch to branch ${br.name}, stash if needed"`)}` : t` ${dim("Press space to select this branch for launch")}` - app.previewText.content = t` ${bold("Branch:")} ${magenta(br.name)} ${dim("Sync:")} ${sync} + app.previewText.content = st( + t` ${bold("Branch:")} ${magenta(br.name)} ${dim("Sync:")} ${sync} ${dim("Last commit:")} ${br.lastCommitAge} — ${br.lastCommitMsg} -${selNote}` +`, selNote) } } else { app.previewText.content = t` ${green("Start a new Claude session")} in ${bold(project.name)} From c2e8fcaa9490c8f7e1b23bc46d9e109e8b6b2feb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 28 Feb 2026 16:38:22 +0000 Subject: [PATCH 10/13] feat: framed buttons, select mode isolation, shift+click select, pane status icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace bare ● and × with framed [●] and [×] for better click targets - Select mode now clears screen and shows only focused pane content - Shift+click auto-enters select mode for native text selection - Ctrl+S keyboard shortcut to toggle select mode from any grid view - Pane list shows status icons: ● green=running, ◉ yellow=idle, ○ dim=unknown - Update hit-testing regions for new 3-char framed button positions - Add ctrl+s select hint to all grid header modes and expanded footer Co-Authored-By: Claude Opus 4.6 --- src/components/direct-grid.ts | 105 ++++++++++++++++++++++------------ src/input/handlers.ts | 9 +++ src/ui/panels.ts | 8 ++- 3 files changed, 83 insertions(+), 39 deletions(-) diff --git a/src/components/direct-grid.ts b/src/components/direct-grid.ts index 88ee3f0..724311d 100644 --- a/src/components/direct-grid.ts +++ b/src/components/direct-grid.ts @@ -185,18 +185,37 @@ export class DirectGridRenderer { get activeTabId() { return this._activeTabId } enterSelectMode() { - if (!this.isExpanded) return this._selectMode = true this.writeRaw("\x1b[?1000l\x1b[?1006l") this.writeRaw(SHOW_CURSOR) - this.drawChrome() + this.drawSelectView() } exitSelectMode() { this._selectMode = false this.writeRaw("\x1b[?1000h\x1b[?1006h") - this.writeRaw(HIDE_CURSOR) - this.drawChrome() + this.writeRaw(HIDE_CURSOR + CLEAR) + this.forceRedrawAll() + } + + private drawSelectView() { + const pane = this.focusedPane + if (!pane) return + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const frame = getLatestFrame(pane.session.name) + const lines = frame?.lines ?? [] + + let out = SYNC_START + CLEAR + // Header: project name + select instructions + const color = getColor(pane.session.colorIndex) + out += `\x1b[1;1H${hexFg(color)}${BOLD}${pane.session.projectName}${RESET} ${DIM}SELECT MODE — drag to select │ cmd+c copy │ Esc exit${RESET}` + // Render pane content starting at row 2, column 1 — flush left, no borders + for (let r = 0; r < Math.min(lines.length, termH - 2); r++) { + out += `\x1b[${r + 2};1H\x1b[${termW}X${lines[r]}\x1b[0m` + } + out += SYNC_END + this.writeRaw(out) } expandPane(index?: number) { @@ -379,22 +398,22 @@ export class DirectGridRenderer { const by = dp.screenY - 3 const bw = dp.width + 2 - // Top border row — traffic light buttons with widened hit areas + // Top border row — framed [●] buttons if (row === by) { if (this.isExpanded) { - // Layout: ...─● ● ●─╮ (close=bw-7, min=bw-5, sel=bw-3) - // sel (rightmost): dot + border + corner = 3 chars - if (col >= bx + bw - 4 && col <= bx + bw - 1) return { action: "sel", paneIndex: i } - // min: dot ± 1 = 3 chars - if (col >= bx + bw - 6 && col <= bx + bw - 4) return { action: "min", paneIndex: i } - // close: border + dot = 2 chars (smaller to avoid accidents) - if (col >= bx + bw - 8 && col <= bx + bw - 6) return { action: "closepane", paneIndex: i } + // Layout: ...─[●] [●] [●]─╮ + // sel: bw-5..[●]..bw-3 + trailing border + if (col >= bx + bw - 5 && col <= bx + bw - 1) return { action: "sel", paneIndex: i } + // min: bw-9..[●]..bw-7 + space + if (col >= bx + bw - 9 && col <= bx + bw - 6) return { action: "min", paneIndex: i } + // close: bw-13..[●]..bw-11 + space + if (col >= bx + bw - 13 && col <= bx + bw - 10) return { action: "closepane", paneIndex: i } } else { - // Layout: ...─● ●─╮ (close=bw-5, max=bw-3) - // max (rightmost): space + dot + border + corner = 4 chars - if (col >= bx + bw - 4 && col <= bx + bw - 1) return { action: "max", paneIndex: i } - // close: border + dot + space = 3 chars - if (col >= bx + bw - 7 && col <= bx + bw - 5) return { action: "closepane", paneIndex: i } + // Layout: ...─[●] [●]─╮ + // max: bw-5..[●]..bw-3 + trailing border + if (col >= bx + bw - 5 && col <= bx + bw - 1) return { action: "max", paneIndex: i } + // close: bw-9..[●]..bw-7 + space + if (col >= bx + bw - 9 && col <= bx + bw - 6) return { action: "closepane", paneIndex: i } } continue } @@ -580,7 +599,7 @@ export class DirectGridRenderer { // ─── Chrome ──────────────────────────────────────────── drawChrome() { - if (!this.running) return + if (!this.running || this._selectMode) return const termW = process.stdout.columns || 120 const termH = process.stdout.rows || 40 @@ -601,13 +620,13 @@ export class DirectGridRenderer { 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}${hexFg("#f7768e")}●${RESET}${DIM} close │ ${hexFg("#e0af68")}●${RESET}${DIM} restore │ ${hexFg("#9ece6a")}●${RESET}${DIM} select │ ctrl+space picker${RESET}` + headerRight = `${DIM}${hexFg("#f7768e")}[●]${RESET}${DIM} close │ ${hexFg("#e0af68")}[●]${RESET}${DIM} restore │ ${hexFg("#9ece6a")}[●]${RESET}${DIM} select │ ctrl+space picker${RESET}` } else if (this.isSoftExpanded) { headerLeft = ` ${BOLD}cladm grid${RESET} — ${hexFg("#bb9af7")}${BOLD}FOCUS${RESET} │ ${fi}/${n}` - headerRight = `${DIM}click pane to focus │ ${hexFg("#9ece6a")}●${RESET}${DIM} fullscreen │ ctrl+e toggle${RESET}` + headerRight = `${DIM}click pane to focus │ ${hexFg("#9ece6a")}[●]${RESET}${DIM} fullscreen │ ctrl+s select │ ctrl+e toggle${RESET}` } else { headerLeft = ` ${BOLD}cladm grid${RESET} — ${n} sessions │ focus: ${fi}/${n}` - headerRight = `${DIM}shift+arrows nav │ ${hexFg("#f7768e")}●${RESET}${DIM} close ${hexFg("#9ece6a")}●${RESET}${DIM} expand │ ctrl+space picker${RESET}` + headerRight = `${DIM}shift+arrows nav │ ${hexFg("#f7768e")}[●]${RESET}${DIM} close ${hexFg("#9ece6a")}[●]${RESET}${DIM} expand │ ctrl+s select │ ctrl+space picker${RESET}` } out += `\x1b[3;1H\x1b[${termW}X${headerLeft} ${headerRight}` @@ -626,7 +645,7 @@ export class DirectGridRenderer { out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg("#9ece6a")}${BOLD}SELECT MODE${RESET} ${DIM}drag to select text │ cmd+c to copy │ press ${BOLD}Esc${RESET}${DIM} to exit${RESET}` } 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 ${hexFg("#e0af68")}●${RESET}${DIM} to restore grid${RESET}` + out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg(color)}▸${RESET} ${BOLD}${pane.session.projectName}${RESET} ${DIM}expanded │ ctrl+s select │ Esc or ${hexFg("#e0af68")}[●]${RESET}${DIM} 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}` : "" @@ -675,9 +694,9 @@ export class DirectGridRenderer { const startCol = col + (isActive ? 2 : 1) // account for ╭ + space or just space const visLen = 2 + label.length // "● " + label - // Close button text - const closeText = isPending ? `${RED_FG}${BOLD}●${RESET}` : `${DIM}×${RESET}` - const closeVisLen = 1 + // Close button text — framed for visibility + const closeText = isPending ? `${RED_FG}${BOLD}[●]${RESET}` : `${DIM}[×]${RESET}` + const closeVisLen = 3 if (isActive) { // Chrome-style raised active tab @@ -732,13 +751,19 @@ export class DirectGridRenderer { const short = name.length > 14 ? name.slice(0, 12) + "…" : name const color = getColor(pane.session.colorIndex) + // Status icon: ● green=running, ◉ yellow=idle, ○ dim=unknown + let statusIcon: string + if (pane.status === "busy") statusIcon = `${hexFg("#9ece6a")}●${RESET}` + else if (pane.status === "idle") statusIcon = `${hexFg("#e0af68")}◉${RESET}` + else statusIcon = `${DIM}○${RESET}` + const startCol = col if (isFocused) { - out += `${hexFg(color)}${BOLD}${short}${RESET}` + out += `${statusIcon}${hexFg(color)}${BOLD}${short}${RESET}` } else { - out += `${DIM}${short}${RESET}` + out += `${statusIcon}${DIM}${short}${RESET}` } - col += short.length + col += 1 + short.length // icon + name this.paneListHitRegions.push({ tabId: tab.id, paneIndex: pi, startCol, endCol: col - 1 }) if (pi < tabPanes.length - 1) { @@ -782,23 +807,23 @@ export class DirectGridRenderer { let out = "" - // Top border with traffic-light buttons (macOS style: close, minimize, expand) - const RED_DOT = `${hexFg("#f7768e")}●${RESET}` // close pane - const YELLOW_DOT = `${hexFg("#e0af68")}●${RESET}` // minimize / collapse - const GREEN_DOT = `${hexFg("#9ece6a")}●${RESET}` // expand / maximize - const DIM_DOT = `${DIM}●${RESET}` + // Top border with traffic-light buttons — framed for visibility + const RED_BTN = `${hexFg("#f7768e")}[●]${RESET}` // close pane + const YELLOW_BTN = `${hexFg("#e0af68")}[●]${RESET}` // minimize / collapse + const GREEN_BTN = `${hexFg("#9ece6a")}[●]${RESET}` // expand / maximize + const DIM_BTN = `${DIM}[●]${RESET}` let btnSection: string let btnVisibleLen: number if (this.isExpanded) { // Expanded: show close · minimize · select(green means select mode) - const selDot = this._selectMode ? `${hexFg("#9ece6a")}${BOLD}●${RESET}` : DIM_DOT - btnSection = `${borderColor}${hz}${RESET}${RED_DOT} ${YELLOW_DOT} ${selDot}${borderColor}` - btnVisibleLen = 1 + 1 + 1 + 1 + 1 + 1 + 1 // ─● ● ● + const selBtn = this._selectMode ? `${hexFg("#9ece6a")}${BOLD}[●]${RESET}` : DIM_BTN + btnSection = `${borderColor}${hz}${RESET}${RED_BTN} ${YELLOW_BTN} ${selBtn}${borderColor}` + btnVisibleLen = 1 + 3 + 1 + 3 + 1 + 3 // ─[●] [●] [●] } else { // Grid: show close · expand - btnSection = `${borderColor}${hz}${RESET}${RED_DOT} ${GREEN_DOT}${borderColor}` - btnVisibleLen = 1 + 1 + 1 + 1 // ─● ● + btnSection = `${borderColor}${hz}${RESET}${RED_BTN} ${GREEN_BTN}${borderColor}` + btnVisibleLen = 1 + 3 + 1 + 3 // ─[●] [●] } const hzFill = Math.max(0, bw - 2 - btnVisibleLen - 1) out += `\x1b[${by};${bx}H${borderColor}${tl}${hz.repeat(hzFill)}${btnSection}${hz}${tr}${RESET}` @@ -840,6 +865,10 @@ export class DirectGridRenderer { // ─── Content rendering ───────────────────────────────── private drawPane(index: number, lines: string[]) { + if (this._selectMode) { + if (index === this._focusIndex) this.drawSelectView() + return + } if (this.isExpanded && index !== this._expandedIndex) return const pane = this.panes[index] if (!pane) return diff --git a/src/input/handlers.ts b/src/input/handlers.ts index 65b7ea6..283b7fc 100644 --- a/src/input/handlers.ts +++ b/src/input/handlers.ts @@ -496,6 +496,13 @@ export async function handleGridInput(rawSequence: string): Promise { return true } + // Ctrl+S → toggle select mode (disable mouse tracking for native text selection) + if (rawSequence === "\x13") { + if (app.directGrid.selectMode) app.directGrid.exitSelectMode() + else app.directGrid.enterSelectMode() + return true + } + // Ctrl+E → toggle click-to-expand if (rawSequence === "\x05") { app.clickExpand = !app.clickExpand @@ -592,6 +599,8 @@ function processGridInput(str: string) { for (const me of mouseEvents) { if (me.btn === 64) { dg.sendScrollToFocused("up", 3); continue } if (me.btn === 65) { dg.sendScrollToFocused("down", 3); continue } + // Shift+click (btn bit 2 = shift modifier) → enter select mode for native text selection + if ((me.btn & 4) && !me.release) { dg.enterSelectMode(); return } if (me.btn === 0 && !me.release) { const btn = dg.checkButtonClick(me.col, me.row) if (btn?.action === "closetab" && btn.tabId !== undefined) { diff --git a/src/ui/panels.ts b/src/ui/panels.ts index 5e8a3cd..56c8ad2 100644 --- a/src/ui/panels.ts +++ b/src/ui/panels.ts @@ -95,7 +95,13 @@ export function updatePaneList() { const short = name.length > 14 ? name.slice(0, 12) + "…" : name const isFocused = app.directGrid!.activeTabId === tab.id && app.directGrid!.focusIndex === pi + // Status icon: ● green=running, ◉ yellow=idle, ○ dim=unknown + const statusIcon = pane.status === "busy" ? green("●") + : pane.status === "idle" ? yellow("◉") + : dim("○") + if (!first) parts.push(dim(" · ")) + parts.push(statusIcon) parts.push(isFocused ? bold(short) : dim(short)) first = false } @@ -126,7 +132,7 @@ export function updateTabBar() { const isActive = app.viewMode === "grid" && app.directGrid?.activeTabId === tab.id const isPending = app.directGrid?.pendingCloseTabId === tab.id const label = `${tab.name} (${count})` - const closeBtn = isPending ? t` ${red(bold("●"))}` : t` ${dim("×")}` + const closeBtn = isPending ? t` ${red(bold("[●]"))}` : t` ${dim("[×]")}` if (isActive) { parts.push(dim("╭"), t` ${cyan("●")} ${bold(label)}`, closeBtn, t` ${dim("╮")}`) From 059004b6f25fe15c54d1ba31792dc16dbe7a74df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 28 Feb 2026 16:46:20 +0000 Subject: [PATCH 11/13] fix: space after pane status icons, prevent repeated idle notification sounds - Add space between status icon and project name in pane list for readability - Add notifiedIdle set to checkTransitions to prevent re-triggering sound after busySessions fluctuations during the same idle period Co-Authored-By: Claude Opus 4.6 --- src/components/direct-grid.ts | 8 ++++---- src/data/monitor.ts | 27 +++++++++++++++++---------- src/ui/panels.ts | 6 +++--- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/components/direct-grid.ts b/src/components/direct-grid.ts index 724311d..3a35ec8 100644 --- a/src/components/direct-grid.ts +++ b/src/components/direct-grid.ts @@ -753,9 +753,9 @@ export class DirectGridRenderer { // Status icon: ● green=running, ◉ yellow=idle, ○ dim=unknown let statusIcon: string - if (pane.status === "busy") statusIcon = `${hexFg("#9ece6a")}●${RESET}` - else if (pane.status === "idle") statusIcon = `${hexFg("#e0af68")}◉${RESET}` - else statusIcon = `${DIM}○${RESET}` + if (pane.status === "busy") statusIcon = `${hexFg("#9ece6a")}● ${RESET}` + else if (pane.status === "idle") statusIcon = `${hexFg("#e0af68")}◉ ${RESET}` + else statusIcon = `${DIM}○ ${RESET}` const startCol = col if (isFocused) { @@ -763,7 +763,7 @@ export class DirectGridRenderer { } else { out += `${statusIcon}${DIM}${short}${RESET}` } - col += 1 + short.length // icon + name + col += 2 + short.length // icon + space + name this.paneListHitRegions.push({ tabId: tab.id, paneIndex: pi, startCol, endCol: col - 1 }) if (pi < tabPanes.length - 1) { diff --git a/src/data/monitor.ts b/src/data/monitor.ts index f9c6a67..a5c8ed0 100644 --- a/src/data/monitor.ts +++ b/src/data/monitor.ts @@ -314,6 +314,7 @@ export function updateProjectSessions(projects: Project[], sessions: Map() // path → timestamp when first went idle +const notifiedIdle = new Set() // paths already notified — prevents re-trigger export function checkTransitions( projects: Project[], @@ -325,20 +326,26 @@ export function checkTransitions( const prev = prevBusy.get(project.path) || 0 const isIdle = project.busySessions === 0 && project.activeSessions > 0 - if (prev > 0 && isIdle && !pendingIdle.has(project.path)) { + if (!isIdle) { + // Not idle — clear notification state so next idle transition can fire + notifiedIdle.delete(project.path) + pendingIdle.delete(project.path) + continue + } + + // Already notified for this idle period — skip + if (notifiedIdle.has(project.path)) continue + + if (prev > 0 && !pendingIdle.has(project.path)) { // Just transitioned busy→idle — start the delay timer pendingIdle.set(project.path, now) } - if (pendingIdle.has(project.path)) { - if (!isIdle) { - // Went busy again — false alarm, cancel - pendingIdle.delete(project.path) - } else if (now - pendingIdle.get(project.path)! >= IDLE_SOUND_DELAY_MS) { - // Confirmed idle for 10+ seconds - transitioned.push(project.name) - pendingIdle.delete(project.path) - } + if (pendingIdle.has(project.path) && now - pendingIdle.get(project.path)! >= IDLE_SOUND_DELAY_MS) { + // Confirmed idle for 10+ seconds — notify once + transitioned.push(project.name) + pendingIdle.delete(project.path) + notifiedIdle.add(project.path) } } return transitioned diff --git a/src/ui/panels.ts b/src/ui/panels.ts index 56c8ad2..3d5692d 100644 --- a/src/ui/panels.ts +++ b/src/ui/panels.ts @@ -96,9 +96,9 @@ export function updatePaneList() { const isFocused = app.directGrid!.activeTabId === tab.id && app.directGrid!.focusIndex === pi // Status icon: ● green=running, ◉ yellow=idle, ○ dim=unknown - const statusIcon = pane.status === "busy" ? green("●") - : pane.status === "idle" ? yellow("◉") - : dim("○") + const statusIcon = pane.status === "busy" ? green("● ") + : pane.status === "idle" ? yellow("◉ ") + : dim("○ ") if (!first) parts.push(dim(" · ")) parts.push(statusIcon) From 9cf18f5740522f5962796b1d73e5b36c399df078 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 28 Feb 2026 17:54:41 +0000 Subject: [PATCH 12/13] fix: PTY stdin error, double-click select with full buffer, alt+key passthrough - Remove proc.stdin.on() call (Bun FileSink has no .on method), add try/catch and proc.killed guards to write/resize - Double-click enters select mode instead of shift+click, with yellow banner and Esc-to-exit hint for discoverability - Select mode now dumps full scrollback buffer (up to 5000 lines) so users can scroll up and copy old conversation text - Pass all Alt+key combos through input parser to PTY (fixes Alt+Backspace word deletion and other Alt shortcuts) Co-Authored-By: Claude Opus 4.6 --- src/components/direct-grid.ts | 31 +++++++++----- src/input/handlers.ts | 79 +++++++++++++++++++++++++++++++---- src/input/parser.ts | 6 ++- src/pty/capture.ts | 14 +++++++ src/pty/session-manager.ts | 8 ++-- 5 files changed, 114 insertions(+), 24 deletions(-) diff --git a/src/components/direct-grid.ts b/src/components/direct-grid.ts index 3a35ec8..a5edae9 100644 --- a/src/components/direct-grid.ts +++ b/src/components/direct-grid.ts @@ -3,7 +3,7 @@ // 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 { startCapture, stopCapture, resizeCapture, resetHash, getLatestFrame, getFullBuffer, scrollPane, getScrollOffset } from "../pty/capture" import { writeToSession, resizeSession, killSession, type PtySession } from "../pty/session-manager" import { app, type GridTab } from "../lib/state" @@ -202,17 +202,26 @@ export class DirectGridRenderer { const pane = this.focusedPane if (!pane) return const termW = process.stdout.columns || 120 - const termH = process.stdout.rows || 40 - const frame = getLatestFrame(pane.session.name) - const lines = frame?.lines ?? [] - - let out = SYNC_START + CLEAR - // Header: project name + select instructions + const lines = getFullBuffer(pane.session.name) ?? [] const color = getColor(pane.session.colorIndex) - out += `\x1b[1;1H${hexFg(color)}${BOLD}${pane.session.projectName}${RESET} ${DIM}SELECT MODE — drag to select │ cmd+c copy │ Esc exit${RESET}` - // Render pane content starting at row 2, column 1 — flush left, no borders - for (let r = 0; r < Math.min(lines.length, termH - 2); r++) { - out += `\x1b[${r + 2};1H\x1b[${termW}X${lines[r]}\x1b[0m` + + // Banner + all buffer lines dumped as plain text (terminal handles native scrollback) + let out = SYNC_START + CLEAR + + // Banner row + const bannerBg = hexBg("#e0af68") + const bannerFg = "\x1b[38;2;0;0;0m" + const bannerText = " SELECTION MODE " + const hint = " Esc to exit " + const pad = Math.max(0, termW - bannerText.length - hint.length) + out += `\x1b[1;1H${bannerBg}${bannerFg}${BOLD}${bannerText}${" ".repeat(pad)}${hint}${RESET}` + + // Project name on row 2 + out += `\x1b[2;1H${hexFg(color)}${BOLD}${pane.session.projectName}${RESET} ${DIM}drag to select │ cmd+c copy │ scroll up for history${RESET}` + + // Dump full buffer starting row 3 — native terminal scrollback handles overflow + for (let r = 0; r < lines.length; r++) { + out += `\x1b[${r + 3};1H${lines[r]}\x1b[0m` } out += SYNC_END this.writeRaw(out) diff --git a/src/input/handlers.ts b/src/input/handlers.ts index 283b7fc..77cbc41 100644 --- a/src/input/handlers.ts +++ b/src/input/handlers.ts @@ -3,7 +3,7 @@ import { app } from "../lib/state" import { updateAll, rebuildDisplayRows, applySortMode, updateTabBar } from "../ui/panels" import { extractKeyboardInput, extractMouseEvents } from "./parser" import { switchToGrid, switchToGridTab, createNewGridTab } from "../grid/view-switch" -import { doLaunch } from "../actions/launch" +import { doLaunch, doAddPane } from "../actions/launch" import { launchSelections } from "../actions/launcher" import { loadSessions } from "../data/sessions" import { loadBranches } from "../data/git" @@ -338,6 +338,27 @@ export async function handleKeypress(key: KeyEvent) { case "g": { const row = app.displayRows[app.cursor] const project = app.projects[row.projectIndex] + + // Try grid pane navigation first + if (app.directGrid && app.gridTabs.length > 0) { + const targetSessionId = row.type === "session" && project.sessions + ? project.sessions[row.sessionIndex!]?.id + : undefined + + for (const tab of app.gridTabs) { + const panes = app.directGrid.getTabPanes(tab.id) + const paneIdx = targetSessionId + ? panes.findIndex(p => p.session.projectPath === project.path && p.session.sessionId === targetSessionId) + : panes.findIndex(p => p.session.projectPath === project.path) + if (paneIdx >= 0) { + switchToGridTab(tab.id) + app.directGrid.setFocus(paneIdx) + return + } + } + } + + // Fallback: external terminal if (project.activeSessions > 0) { const sid = row.type === "session" && project.sessions ? project.sessions[row.sessionIndex!]?.id @@ -380,7 +401,18 @@ export async function handleKeypress(key: KeyEvent) { case "return": { const hasSelections = app.selectedProjects.size > 0 || app.selectedSessions.size > 0 if (hasSelections) { - doLaunch() + if (app.addPaneTargetTabId !== null) { + doAddPane() + } else { + doLaunch() + } + break + } + if (app.addPaneTargetTabId !== null) { + // In add-pane mode with no explicit selections, add cursor item + const addRow = app.displayRows[app.cursor] + if (addRow) app.selectedProjects.set(app.projects[addRow.projectIndex].path, 1) + doAddPane() break } if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0 && app.idleCursor < app.cachedIdleSessions.length) { @@ -444,6 +476,16 @@ export async function handleKeypress(key: KeyEvent) { } case "escape": + if (app.addPaneTargetTabId !== null) { + // Cancel add-pane mode, return to grid + const returnTabId = app.addPaneTargetTabId + app.addPaneTargetTabId = null + app.selectedProjects.clear() + app.selectedSessions.clear() + app.selectedBranches.clear() + switchToGridTab(returnTabId) + return + } if (app.restoreMode === "pending") { app.restoreMode = null break @@ -468,7 +510,7 @@ export async function handleKeypress(key: KeyEvent) { } updateAll() - } catch {} + } catch (err) { console.error("[handleKeypress]", err) } } // ─── Grid input ────────────────────────────────────────────────────── @@ -521,8 +563,13 @@ export async function handleGridInput(rawSequence: string): Promise { if (rawSequence === "\x1bn") { handleNextTab(); return true } if (rawSequence === "\x1bp") { handlePrevTab(); return true } - // Ctrl+N / Ctrl+P → focus next/prev pane - if (rawSequence === "\x0e") { app.directGrid.focusNext(); return true } + // Ctrl+N → add pane to current tab (enter picker in add-pane mode) + if (rawSequence === "\x0e") { + app.addPaneTargetTabId = app.directGrid.activeTabId + switchToPicker() + return true + } + // Ctrl+P → focus prev pane if (rawSequence === "\x10") { app.directGrid.focusPrev(); return true } // Ctrl+F → open folder @@ -585,6 +632,24 @@ export function switchToPicker() { app.renderer.requestRender() } +// ─── Double-click detection ────────────────────────────────────────── + +let _lastClickTime = 0 +let _lastClickCol = 0 +let _lastClickRow = 0 +const DOUBLE_CLICK_MS = 400 +const DOUBLE_CLICK_DIST = 2 + +function isDoubleClick(col: number, row: number): boolean { + const now = Date.now() + const dt = now - _lastClickTime + const dist = Math.abs(col - _lastClickCol) + Math.abs(row - _lastClickRow) + _lastClickTime = now + _lastClickCol = col + _lastClickRow = row + return dt < DOUBLE_CLICK_MS && dist <= DOUBLE_CLICK_DIST +} + // ─── Stdin: grid mode ──────────────────────────────────────────────── function processGridInput(str: string) { @@ -599,9 +664,9 @@ function processGridInput(str: string) { for (const me of mouseEvents) { if (me.btn === 64) { dg.sendScrollToFocused("up", 3); continue } if (me.btn === 65) { dg.sendScrollToFocused("down", 3); continue } - // Shift+click (btn bit 2 = shift modifier) → enter select mode for native text selection - if ((me.btn & 4) && !me.release) { dg.enterSelectMode(); return } if (me.btn === 0 && !me.release) { + // Double-click → enter select mode for native text selection + if (isDoubleClick(me.col, me.row)) { dg.enterSelectMode(); return } const btn = dg.checkButtonClick(me.col, me.row) if (btn?.action === "closetab" && btn.tabId !== undefined) { const result = dg.requestCloseTab(btn.tabId) diff --git a/src/input/parser.ts b/src/input/parser.ts index ad62e74..0d30633 100644 --- a/src/input/parser.ts +++ b/src/input/parser.ts @@ -65,8 +65,10 @@ export function extractKeyboardInput(data: string): string { i += 3; continue } - // Alt+digit (1-9) and Alt+letter (n, p) — keep as keyboard shortcuts - if ((next >= "1" && next <= "9") || next === "n" || next === "p") { + // Alt+key combos: \x1b + printable/control char — pass through to PTY + // Includes Alt+Backspace (\x1b\x7f), Alt+digits, Alt+letters, etc. + const nc = data.charCodeAt(i + 1) + if ((nc >= 0x20 && nc <= 0x7e) || nc === 0x7f) { keyboard += data.slice(i, i + 2) i += 2; continue } diff --git a/src/pty/capture.ts b/src/pty/capture.ts index 7c3bdd4..b949f7c 100644 --- a/src/pty/capture.ts +++ b/src/pty/capture.ts @@ -195,6 +195,14 @@ class VtScreen { } } + // Get full buffer: all scrollback lines + current screen (for select mode) + getAllLines(): string[] { + const lines: string[] = [] + for (const row of this.scrollback) lines.push(this.renderRow(row)) + for (let r = 0; r < this.height; r++) lines.push(this.renderRow(this.cells[r])) + return lines + } + // Get screen as lines with embedded ANSI SGR codes (like tmux capture-pane -e) // When scrollOffset > 0, shows scrollback history mixed with screen content getLines(): string[] { @@ -613,6 +621,12 @@ export function getLatestFrame(sessionName: string): CaptureResult | null { } } +export function getFullBuffer(sessionName: string): string[] | null { + const state = panes.get(sessionName) + if (!state) return null + return state.screen.getAllLines() +} + export function stopCapture(sessionName: string): void { const state = panes.get(sessionName) if (!state) return diff --git a/src/pty/session-manager.ts b/src/pty/session-manager.ts index eeb106a..5879f09 100644 --- a/src/pty/session-manager.ts +++ b/src/pty/session-manager.ts @@ -100,18 +100,18 @@ export function killSession(name: string): void { export function resizeSession(name: string, width: number, height: number): void { const session = sessions.get(name) - if (!session || !session.alive) return + if (!session || !session.alive || session.proc.killed) return session.width = width session.height = height // Send APC resize command: \x1b_R;\x1b\\ const resizeCmd = `\x1b_R${height};${width}\x1b\\` - session.proc.stdin.write(resizeCmd) + try { session.proc.stdin.write(resizeCmd) } catch {} } export function writeToSession(name: string, data: string): void { const session = sessions.get(name) - if (!session || !session.alive) return - session.proc.stdin.write(data) + if (!session || !session.alive || session.proc.killed) return + try { session.proc.stdin.write(data) } catch {} } export function isAlive(name: string): boolean { From e0f1a08098565e9922c6305be491cd206193e925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:13:25 +0000 Subject: [PATCH 13/13] feat: inline pane tabs, add-pane mode, and grid chrome cleanup - Tab bar now shows pane names inline with status icons instead of generic tab names with counts, eliminating the separate pane list row - chromeTop reduced from 4 to 3, gaining one extra row of content space - Add-pane mode (Ctrl+N) lets users add panes to existing tabs from picker - Picker tab bar updated to match inline pane name format - Session formatters and launch actions updated for branch switching Co-Authored-By: Claude Opus 4.6 --- src/actions/launch.ts | 61 +++++++++++ src/components/direct-grid.ts | 197 ++++++++++++++++++---------------- src/index.ts | 6 +- src/input/handlers.ts | 22 ++-- src/lib/state.ts | 1 + src/ui/formatters.ts | 38 ++++++- src/ui/panels.ts | 37 +++++-- 7 files changed, 246 insertions(+), 116 deletions(-) diff --git a/src/actions/launch.ts b/src/actions/launch.ts index be05019..c3093f1 100644 --- a/src/actions/launch.ts +++ b/src/actions/launch.ts @@ -4,6 +4,67 @@ import { ensureGridView, createNewGridTab, switchToGridTab } from "../grid/view- import { loadSessions } from "../data/sessions" import { createSession } from "../pty/session-manager" +export async function doAddPane() { + const targetTabId = app.addPaneTargetTabId + if (!targetTabId || !app.directGrid) return + if (app.selectedProjects.size === 0 && app.selectedSessions.size === 0) return + + type LaunchItem = { path: string; name: string; sessionId?: string; targetBranch?: string } + const items: LaunchItem[] = [] + + for (const [path] of app.selectedProjects) { + const project = app.projects.find(p => p.path === path) + if (!project) continue + const targetBranch = app.selectedBranches.get(path) + const needsBranch = targetBranch && targetBranch !== project.branch + if (!project.sessions) { + project.sessions = await loadSessions(project.path) + project.sessionCount = project.sessions.length + } + const lastSessionId = project.sessions[0]?.id + items.push({ path, name: project.name, sessionId: lastSessionId, targetBranch: needsBranch ? targetBranch : undefined }) + } + + for (const project of app.projects) { + if (!project.sessions) continue + for (const session of project.sessions) { + if (app.selectedSessions.has(session.id)) { + const targetBranch = app.selectedBranches.get(project.path) + const needsBranch = targetBranch && targetBranch !== project.branch + items.push({ path: project.path, name: project.name, sessionId: session.id, targetBranch: needsBranch ? targetBranch : undefined }) + } + } + } + + if (items.length === 0) return + + const termW = process.stdout.columns || 120 + const termH = process.stdout.rows || 40 + const totalPanes = items.length + (app.directGrid.getTabPaneCount(targetTabId) || 0) + const cols = totalPanes <= 1 ? 1 : totalPanes <= 2 ? 2 : totalPanes <= 4 ? 2 : 3 + const rows = Math.ceil(totalPanes / cols) + const paneW = Math.max(Math.floor(termW / cols) - 2, 20) + const paneH = Math.max(Math.floor((termH - 2) / rows) - 4, 6) + + for (const item of items) { + const session = await createSession({ + projectPath: item.path, + projectName: item.name, + sessionId: item.sessionId, + targetBranch: item.targetBranch, + width: paneW, + height: paneH, + }) + await app.directGrid.addPane(session, targetTabId) + } + + app.selectedProjects.clear() + app.selectedSessions.clear() + app.selectedBranches.clear() + app.addPaneTargetTabId = null + switchToGridTab(targetTabId) +} + export async function doLaunch() { if (app.selectedProjects.size === 0 && app.selectedSessions.size === 0) return if (app.demoMode) { diff --git a/src/components/direct-grid.ts b/src/components/direct-grid.ts index a5edae9..5968da7 100644 --- a/src/components/direct-grid.ts +++ b/src/components/direct-grid.ts @@ -86,7 +86,7 @@ export class DirectGridRenderer { private tabBarHitRegions: { tabId: number, startCol: number, endCol: number }[] = [] private tabCloseHitRegions: { tabId: number, startCol: number, endCol: number }[] = [] private tabBarAddBtnCol = -1 - // Pane list hit-test regions (row 2) + // Pane name hit-test regions (inline in tab bar, row 1) private paneListHitRegions: { tabId: number, paneIndex: number, startCol: number, endCol: number }[] = [] // Pending close state @@ -371,7 +371,7 @@ export class DirectGridRenderer { // Check if a click hit a button on the top border. Returns action + pane index. // Hit areas are widened beyond the visible dot characters to make clicking easier. checkButtonClick(col: number, row: number): { action: "max" | "min" | "sel" | "tab" | "newtab" | "panefocus" | "closetab" | "closepane", paneIndex: number, tabId?: number } | null { - // Tab bar check (row 1) + // Tab bar check (row 1) — includes inline pane names if (row === 1) { // Check close buttons first — widened ±1 around the × character for (const region of this.tabCloseHitRegions) { @@ -379,6 +379,12 @@ export class DirectGridRenderer { return { action: "closetab", paneIndex: -1, tabId: region.tabId } } } + // Pane names (inline in tabs) — check before tab regions since they're more specific + for (const region of this.paneListHitRegions) { + if (col >= region.startCol - 1 && col <= region.endCol + 1) { + return { action: "panefocus", paneIndex: region.paneIndex, tabId: region.tabId } + } + } for (const region of this.tabBarHitRegions) { if (col >= region.startCol && col <= region.endCol) { return { action: "tab", paneIndex: -1, tabId: region.tabId } @@ -390,15 +396,6 @@ export class DirectGridRenderer { } return null } - // Pane list check (row 2) — widened ±1 for easier clicks - if (row === 2) { - for (const region of this.paneListHitRegions) { - if (col >= region.startCol - 1 && col <= region.endCol + 1) { - return { action: "panefocus", paneIndex: region.paneIndex, tabId: region.tabId } - } - } - return null - } const indicesToCheck = this.isExpanded ? [this._expandedIndex] : this.panes.map((_, i) => i) for (const i of indicesToCheck) { @@ -562,7 +559,7 @@ export class DirectGridRenderer { if (n === 0) return false const termW = process.stdout.columns || 120 const termH = process.stdout.rows || 40 - const chromeTop = 4 + const chromeTop = 3 const { cols } = this.calcGrid(n) const rows = Math.ceil(n / cols) const cellW = Math.floor(termW / cols) @@ -594,7 +591,7 @@ export class DirectGridRenderer { if (n === 0) return -1 const termW = process.stdout.columns || 120 const termH = process.stdout.rows || 40 - const chromeTop = 4 + const chromeTop = 3 const { cols } = this.calcGrid(n) const rows = Math.ceil(n / cols) const cellW = Math.floor(termW / cols) @@ -614,13 +611,10 @@ export class DirectGridRenderer { let out = SYNC_START - // Tab bar (row 1) + // Tab bar (row 1) — includes inline pane names out += this.drawTabBar(termW) - // Pane list (row 2) - out += this.drawPaneList(termW) - - // Header (row 3) + // Header (row 2) const n = this.panes.length const fi = this._focusIndex + 1 let headerLeft: string, headerRight: string @@ -637,7 +631,7 @@ export class DirectGridRenderer { headerLeft = ` ${BOLD}cladm grid${RESET} — ${n} sessions │ focus: ${fi}/${n}` headerRight = `${DIM}shift+arrows nav │ ${hexFg("#f7768e")}[●]${RESET}${DIM} close ${hexFg("#9ece6a")}[●]${RESET}${DIM} expand │ ctrl+s select │ ctrl+space picker${RESET}` } - out += `\x1b[3;1H\x1b[${termW}X${headerLeft} ${headerRight}` + out += `\x1b[2;1H\x1b[${termW}X${headerLeft} ${headerRight}` // Pane borders + titles if (this.isExpanded) { @@ -671,6 +665,7 @@ export class DirectGridRenderer { private drawTabBar(termW: number): string { this.tabBarHitRegions = [] this.tabCloseHitRegions = [] + this.paneListHitRegions = [] this.tabBarAddBtnCol = -1 const RED_FG = hexFg("#f7768e") @@ -688,49 +683,106 @@ export class DirectGridRenderer { out += ` ${DIM}○ Picker${RESET} ` } const pickerStart = pickerActive ? col + 1 : col + 1 - const pickerVisLen = pickerActive ? 10 : 10 this.tabBarHitRegions.push({ tabId: -1, startCol: pickerStart, endCol: pickerStart + 7 }) - col += pickerVisLen + col += 10 - // Grid tabs + // Grid tabs — inline pane names instead of tab names for (const tab of app.gridTabs) { const isActive = this._activeTabId === tab.id && app.viewMode === "grid" - const hasIdle = this.hasIdleInTab(tab.id) - const count = this.getTabPaneCount(tab.id) - const label = `${tab.name} (${count})` const isPending = this._pendingCloseTabId === tab.id + const tabPanes = this.tabPanes.get(tab.id) ?? [] - const startCol = col + (isActive ? 2 : 1) // account for ╭ + space or just space - const visLen = 2 + label.length // "● " + label + // Build pane name list for this tab + const paneLabels: { name: string, color: string, status: PaneStatus, isFocused: boolean }[] = [] + for (let pi = 0; pi < tabPanes.length; pi++) { + const p = tabPanes[pi]! + const name = p.session.projectName + const short = name.length > 14 ? name.slice(0, 12) + "…" : name + paneLabels.push({ + name: short, + color: getColor(p.session.colorIndex), + status: p.status, + isFocused: isActive && this._focusIndex === pi, + }) + } - // Close button text — framed for visibility + // Close button text const closeText = isPending ? `${RED_FG}${BOLD}[●]${RESET}` : `${DIM}[×]${RESET}` const closeVisLen = 3 + const tabStartCol = col + if (isActive) { - // Chrome-style raised active tab - out += `${TAB_BORDER}╭${RESET}${TAB_BG_ACTIVE} ${CYAN_FG}${BOLD}● ${label}${RESET}${TAB_BG_ACTIVE} ${closeText}${TAB_BG_ACTIVE} ${RESET}${TAB_BORDER}╮${RESET}` - // ╭ + space + ● label + space + × + space + ╮ - const totalVis = 1 + 1 + visLen + 1 + closeVisLen + 1 + 1 - this.tabBarHitRegions.push({ tabId: tab.id, startCol, endCol: startCol + visLen - 1 }) - const closeStartCol = startCol + visLen + 1 - this.tabCloseHitRegions.push({ tabId: tab.id, startCol: closeStartCol, endCol: closeStartCol + closeVisLen - 1 }) - col += totalVis - } else { - // Inactive tab — flat, no border - let indicator: string - if (hasIdle) { - indicator = `${YELLOW_FG}◉ ${label}${RESET}` - } else { - indicator = `${DIM}○ ${label}${RESET}` + // Active tab: ╭ ● pane1 · ◉ pane2 × ╮ + out += `${TAB_BORDER}╭${RESET}${TAB_BG_ACTIVE} ` + col += 2 // ╭ + space + + for (let pi = 0; pi < paneLabels.length; pi++) { + const pl = paneLabels[pi]! + let icon: string + if (pl.status === "busy") icon = `${hexFg("#9ece6a")}●${RESET}` + else if (pl.status === "idle") icon = `${hexFg("#e0af68")}◉${RESET}` + else icon = `${DIM}○${RESET}` + + const paneStartCol = col + if (pl.isFocused) { + out += `${TAB_BG_ACTIVE}${icon} ${hexFg(pl.color)}${BOLD}${pl.name}${RESET}` + } else { + out += `${TAB_BG_ACTIVE}${icon} ${DIM}${pl.name}${RESET}` + } + col += 2 + pl.name.length // icon + space + name + this.paneListHitRegions.push({ tabId: tab.id, paneIndex: pi, startCol: paneStartCol, endCol: col - 1 }) + + if (pi < paneLabels.length - 1) { + out += `${TAB_BG_ACTIVE}${DIM} · ${RESET}` + col += 3 + } } - out += ` ${indicator} ${closeText} ${DIM}│${RESET}` - // space + ● label + space + × + space + │ - const totalVis = 1 + visLen + 1 + closeVisLen + 1 + 1 - this.tabBarHitRegions.push({ tabId: tab.id, startCol, endCol: startCol + visLen - 1 }) - const closeStartCol = startCol + visLen + 1 + + if (paneLabels.length === 0) { + out += `${TAB_BG_ACTIVE}${DIM}empty${RESET}` + col += 5 + } + + out += `${TAB_BG_ACTIVE} ${closeText}${TAB_BG_ACTIVE} ${RESET}${TAB_BORDER}╮${RESET}` + const closeStartCol = col + 1 + col += 1 + closeVisLen + 1 + 1 // space + [×] + space + ╮ this.tabCloseHitRegions.push({ tabId: tab.id, startCol: closeStartCol, endCol: closeStartCol + closeVisLen - 1 }) - col += totalVis + this.tabBarHitRegions.push({ tabId: tab.id, startCol: tabStartCol, endCol: col - 1 }) + } else { + // Inactive tab: ○ pane1 · pane2 × │ + const hasIdle = this.hasIdleInTab(tab.id) + out += ` ` + col += 1 + + for (let pi = 0; pi < paneLabels.length; pi++) { + const pl = paneLabels[pi]! + let icon: string + if (pl.status === "idle") icon = `${YELLOW_FG}◉${RESET}` + else if (pl.status === "busy") icon = `${DIM}●${RESET}` + else icon = `${DIM}○${RESET}` + + const paneStartCol = col + out += `${icon} ${DIM}${pl.name}${RESET}` + col += 2 + pl.name.length + this.paneListHitRegions.push({ tabId: tab.id, paneIndex: pi, startCol: paneStartCol, endCol: col - 1 }) + + if (pi < paneLabels.length - 1) { + out += `${DIM} · ${RESET}` + col += 3 + } + } + + if (paneLabels.length === 0) { + out += `${DIM}empty${RESET}` + col += 5 + } + + out += ` ${closeText} ${DIM}│${RESET}` + const closeStartCol = col + 1 + col += 1 + closeVisLen + 1 + 1 // space + [×] + space + │ + this.tabCloseHitRegions.push({ tabId: tab.id, startCol: closeStartCol, endCol: closeStartCol + closeVisLen - 1 }) + this.tabBarHitRegions.push({ tabId: tab.id, startCol: tabStartCol, endCol: col - 1 }) } } @@ -743,50 +795,7 @@ export class DirectGridRenderer { return out } - private drawPaneList(termW: number): string { - this.paneListHitRegions = [] - let out = `\x1b[2;1H\x1b[${termW}X ` - let col = 3 - // Show panes across all tabs, grouped by tab - for (const tab of app.gridTabs) { - const tabPanes = this.tabPanes.get(tab.id) ?? [] - if (tabPanes.length === 0) continue - - for (let pi = 0; pi < tabPanes.length; pi++) { - const pane = tabPanes[pi]! - const isFocused = this._activeTabId === tab.id && this._focusIndex === pi - const name = pane.session.projectName - const short = name.length > 14 ? name.slice(0, 12) + "…" : name - const color = getColor(pane.session.colorIndex) - - // Status icon: ● green=running, ◉ yellow=idle, ○ dim=unknown - let statusIcon: string - if (pane.status === "busy") statusIcon = `${hexFg("#9ece6a")}● ${RESET}` - else if (pane.status === "idle") statusIcon = `${hexFg("#e0af68")}◉ ${RESET}` - else statusIcon = `${DIM}○ ${RESET}` - - const startCol = col - if (isFocused) { - out += `${statusIcon}${hexFg(color)}${BOLD}${short}${RESET}` - } else { - out += `${statusIcon}${DIM}${short}${RESET}` - } - col += 2 + short.length // icon + space + name - this.paneListHitRegions.push({ tabId: tab.id, paneIndex: pi, startCol, endCol: col - 1 }) - - if (pi < tabPanes.length - 1) { - out += `${DIM} · ${RESET}` - col += 3 - } - } - - out += `${DIM} │ ${RESET}` - col += 5 - } - - return out - } private drawPaneBorder(index: number): string { const pane = this.panes[index]! @@ -973,7 +982,7 @@ export class DirectGridRenderer { const n = count ?? this.panes.length const termW = process.stdout.columns || 120 const termH = process.stdout.rows || 40 - const chromeTop = 4 // row 1 = tab bar, row 2 = pane list, row 3 = header, content starts row 4 + const chromeTop = 3 // row 1 = tab bar (with inline panes), row 2 = header, content starts row 3 const { cols, rows } = this.calcGrid(n) const cellW = Math.floor(termW / cols) const cellH = Math.floor((termH - chromeTop - 1) / rows) // -1 for footer @@ -999,7 +1008,7 @@ export class DirectGridRenderer { repositionAll() { const termW = process.stdout.columns || 120 const termH = process.stdout.rows || 40 - const chromeTop = 4 + const chromeTop = 3 if (this.isExpanded) { // Fullscreen: expanded pane gets all space diff --git a/src/index.ts b/src/index.ts index 1251798..82c2eae 100755 --- a/src/index.ts +++ b/src/index.ts @@ -72,7 +72,7 @@ async function main() { try { const state = extractSessionState() if (state) saveSessionSync(state) - } catch {} + } catch (err) { console.error("[session-save]", err) } if (app.monitorInterval) { clearInterval(app.monitorInterval); app.monitorInterval = null } if (app.directGrid) app.directGrid.destroyAll() stopAllCaptures() @@ -187,7 +187,7 @@ async function main() { getUsageSummary().then(u => { app.cachedUsage = u updateUsagePanel() - }).catch(() => {}) + }).catch(err => console.error("[usage]", err)) // Resize PTY panes when terminal window is resized process.stdout.on("resize", () => { @@ -220,7 +220,7 @@ async function main() { try { app.cachedUsage = await getUsageSummary() updateUsagePanel() - } catch {} + } catch (err) { console.error("[usage-poll]", err) } } if (app.demoMode) { diff --git a/src/input/handlers.ts b/src/input/handlers.ts index 77cbc41..60489eb 100644 --- a/src/input/handlers.ts +++ b/src/input/handlers.ts @@ -225,19 +225,25 @@ function handlePickerTabBarClick(col: number, screenRow: number) { c = 11 for (const tab of app.gridTabs) { - const count = app.directGrid?.getTabPaneCount(tab.id) ?? 0 const isActive = app.viewMode === "grid" && app.directGrid?.activeTabId === tab.id - const label = `${tab.name} (${count})` - const visLen = 2 + label.length // "● " + label + + // Build inline pane name list to calculate width + const tabPanes = app.directGrid?.getTabPanes(tab.id) ?? [] + const paneNames = tabPanes.map(p => { + const name = p.session.projectName + return name.length > 14 ? name.slice(0, 12) + "…" : name + }) + const inlineLabel = paneNames.length > 0 ? paneNames.join(" · ") : "empty" + const visLen = 2 + inlineLabel.length // "● " + label const dg = app.directGrid if (isActive) { - // Active: ╭ ● label × ╮ → c+1=╭, c+2=space, c+3..=● label, then close, ╮ + // Active: ╭ ● panes × ╮ const labelStart = c + 2 const labelEnd = labelStart + visLen - 1 - const closeCol = labelEnd + 2 // space + × position - const totalVis = 1 + 1 + visLen + 1 + 1 + 1 + 1 // ╭ + sp + label + sp + × + sp + ╮ + const closeCol = labelEnd + 2 + const totalVis = 1 + 1 + visLen + 1 + 1 + 1 + 1 if (col === closeCol && dg) { const result = dg.requestCloseTab(tab.id) @@ -251,11 +257,11 @@ function handlePickerTabBarClick(col: number, screenRow: number) { } c += totalVis } else { - // Inactive: sp ● label sp × sp │ → 1 + visLen + 1 + 1 + 1 + 1 + // Inactive: sp ● panes sp × sp │ const labelStart = c + 1 const labelEnd = labelStart + visLen - 1 const closeCol = labelEnd + 2 - const totalVis = 1 + visLen + 1 + 1 + 1 + 1 // sp + label + sp + × + sp + │ + const totalVis = 1 + visLen + 1 + 1 + 1 + 1 if (col === closeCol && dg) { const result = dg.requestCloseTab(tab.id) diff --git a/src/lib/state.ts b/src/lib/state.ts index c13fba0..4305f5a 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -49,6 +49,7 @@ export const app = { lastGridTabIndex: 0, // last active grid tab for Ctrl+Space toggle savedSession: null as SavedSession | null, restoreMode: null as "pending" | null, + addPaneTargetTabId: null as number | null, // UI refs (set during init) renderer: null as unknown as CliRenderer, diff --git a/src/ui/formatters.ts b/src/ui/formatters.ts index 8acc010..630a8e4 100644 --- a/src/ui/formatters.ts +++ b/src/ui/formatters.ts @@ -22,7 +22,7 @@ export function fmtSyncIndicator(ahead: number, behind: number): string { return parts.join("") } -const TAB_COLORS = [ +export const TAB_COLORS = [ cyan, // 1 green, // 2 yellow, // 3 @@ -34,6 +34,33 @@ const TAB_COLORS = [ (s: string) => fg("#b4f9f8")(s), // 9 ] +function getGridTabBadges(projectPath: string): string { + if (!app.directGrid || app.gridTabs.length === 0) return "" + const badges: string[] = [] + for (const tab of app.gridTabs) { + const panes = app.directGrid.getTabPanes(tab.id) + if (panes.some(p => p.session.projectPath === projectPath)) { + const displayIdx = app.gridTabs.indexOf(tab) + 1 + const color = TAB_COLORS[(displayIdx - 1) % TAB_COLORS.length]! + badges.push(color(`T${displayIdx}`)) + } + } + return badges.length > 0 ? badges.join("") + " " : "" +} + +function getSessionGridTabBadge(projectPath: string, sessionId: string): string { + if (!app.directGrid || app.gridTabs.length === 0) return "" + for (const tab of app.gridTabs) { + const panes = app.directGrid.getTabPanes(tab.id) + if (panes.some(p => p.session.projectPath === projectPath && p.session.sessionId === sessionId)) { + const displayIdx = app.gridTabs.indexOf(tab) + 1 + const color = TAB_COLORS[(displayIdx - 1) % TAB_COLORS.length]! + return " " + color(`T${displayIdx}`) + } + } + return "" +} + function fmtTabCheck(tabNum: number | undefined) { if (tabNum === undefined) return " " const color = TAB_COLORS[(tabNum - 1) % TAB_COLORS.length]! @@ -83,7 +110,8 @@ export function fmtProjectRow(project: import("../lib/types").Project, isSelecte else if (ca.includes("d ago")) claudeCol = green(ca.padEnd(9)) else claudeCol = dim(ca.padEnd(9)) - return t` ${activeDot}${activeTag}[${check}] ${dim(arrow)} ${name.padEnd(28)} ${magenta(branch.padEnd(9))}${syncCol}${dim( + const gridBadge = getGridTabBadges(project.path) + return t` ${activeDot}${activeTag}${gridBadge}[${check}] ${dim(arrow)} ${name.padEnd(28)} ${magenta(branch.padEnd(9))}${syncCol}${dim( (project.commitAge || "-").padEnd(10) )}${(project.commitMsg || "-").padEnd(22)}${dirtyCol}${claudeCol}${dim( String(project.sessionCount).padStart(3) @@ -120,17 +148,19 @@ export function fmtSessionRow( : session.lastAssistantMsg : "(no text response)" + const tabBadge = session.id ? getSessionGridTabBadge(project.path, session.id) : "" + if (status === "busy") { return t` ${green("●")} ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim( size.padEnd(7) - )} ${fg(ACCENT)('"' + title + '"')} ${green("running")} + )} ${fg(ACCENT)('"' + title + '"')} ${green("running")}${tabBadge} ${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')} ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}` } if (status === "idle") { return t` ${yellow("◉")} ${dim(prefix)} [${check}] ${dim(age.padEnd(9))} ${dim( size.padEnd(7) - )} ${fg(ACCENT)('"' + title + '"')} ${yellow("idle")} + )} ${fg(ACCENT)('"' + title + '"')} ${yellow("idle")}${tabBadge} ${dim("│")} ${dim("You:")} ${fg(ACCENT)('"' + promptText + '"')} ${dim("│")} ${dim("Claude:")} ${fg(ACCENT)('"' + responseText + '"')}` } diff --git a/src/ui/panels.ts b/src/ui/panels.ts index 3d5692d..7285a45 100644 --- a/src/ui/panels.ts +++ b/src/ui/panels.ts @@ -125,21 +125,27 @@ export function updateTabBar() { parts.push(t` ${dim("○ Picker")} `) } - // Grid tabs + // Grid tabs — inline pane names for (const tab of app.gridTabs) { - const count = app.directGrid?.getTabPaneCount(tab.id) ?? 0 const hasIdle = app.directGrid?.hasIdleInTab(tab.id) ?? false const isActive = app.viewMode === "grid" && app.directGrid?.activeTabId === tab.id const isPending = app.directGrid?.pendingCloseTabId === tab.id - const label = `${tab.name} (${count})` const closeBtn = isPending ? t` ${red(bold("[●]"))}` : t` ${dim("[×]")}` + // Build inline pane name list + const tabPanes = app.directGrid?.getTabPanes(tab.id) ?? [] + const paneNames = tabPanes.map(p => { + const name = p.session.projectName + return name.length > 14 ? name.slice(0, 12) + "…" : name + }) + const inlineLabel = paneNames.length > 0 ? paneNames.join(" · ") : "empty" + if (isActive) { - parts.push(dim("╭"), t` ${cyan("●")} ${bold(label)}`, closeBtn, t` ${dim("╮")}`) + parts.push(dim("╭"), t` ${cyan("●")} ${bold(inlineLabel)}`, closeBtn, t` ${dim("╮")}`) } else if (hasIdle) { - parts.push(t` ${yellow("◉")} ${label}`, closeBtn, " ", sep) + parts.push(t` ${yellow("◉")} ${dim(inlineLabel)}`, closeBtn, " ", sep) } else { - parts.push(t` ${dim("○ " + label)}`, closeBtn, " ", sep) + parts.push(t` ${dim("○ " + inlineLabel)}`, closeBtn, " ", sep) } } @@ -151,11 +157,22 @@ export function updateTabBar() { export function updateHeader() { const total = app.selectedProjects.size + app.selectedSessions.size + const modeLabel = app.demoMode ? " [DEMO]" : "" + + // Add-pane mode: show target tab context + if (app.addPaneTargetTabId !== null) { + const targetTab = app.gridTabs.find(t => t.id === app.addPaneTargetTabId) + const tabName = targetTab?.name ?? `Tab ${app.addPaneTargetTabId}` + app.headerText.content = t` ${bold("cladm")}${yellow(modeLabel)} — ${cyan(bold(`Adding to: ${tabName}`))} │ ${String(total)} selected ${dim( + `sort: ${app.sortLabels[app.sortMode]} │ ${app.projects.length} projects` + )}` + return + } + // Count distinct tab groups const tabGroups = new Set(app.selectedProjects.values()) const tabNote = tabGroups.size > 1 ? ` → ${tabGroups.size} tabs` : "" const branchNote = app.selectedBranches.size > 0 ? ` (${app.selectedBranches.size} branch switch)` : "" - const modeLabel = app.demoMode ? " [DEMO]" : "" const activeCount = app.projects.reduce((sum, p) => sum + (p.activeSessions > 0 ? 1 : 0), 0) const busyCount = app.projects.reduce((sum, p) => sum + (p.busySessions > 0 ? 1 : 0), 0) const idleCount = activeCount - busyCount @@ -192,6 +209,12 @@ export function updateFooter() { restoreHint = ` │ r restore (${paneCount}p, ${ago})` } + // Add-pane mode: simplified footer + if (app.addPaneTargetTabId !== null) { + app.footerText.content = t` ${dim("↑↓ nav │ space select │ → expand │ ← collapse │ enter add │ esc cancel")}` + return + } + if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0) { app.footerText.content = t` ${dim( "↑↓ nav │ tab/shift-tab idle select │ enter focus │ i preview │ space select │ a all │ n none │ s sort │ q quit" + gridHint + restoreHint