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 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-28 17:54:41 +00:00
parent 059004b6f2
commit 9cf18f5740
5 changed files with 114 additions and 24 deletions

View File

@@ -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)

View File

@@ -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) {
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<boolean> {
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)

View File

@@ -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
}

View File

@@ -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

View File

@@ -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<rows>;<cols>\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 {