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] 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 {