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. // Each pane renders independently via PTY capture push callbacks.
import { DirectPane } from "./direct-pane" 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 { writeToSession, resizeSession, killSession, type PtySession } from "../pty/session-manager"
import { app, type GridTab } from "../lib/state" import { app, type GridTab } from "../lib/state"
@@ -202,17 +202,26 @@ export class DirectGridRenderer {
const pane = this.focusedPane const pane = this.focusedPane
if (!pane) return if (!pane) return
const termW = process.stdout.columns || 120 const termW = process.stdout.columns || 120
const termH = process.stdout.rows || 40 const lines = getFullBuffer(pane.session.name) ?? []
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) 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 // Banner + all buffer lines dumped as plain text (terminal handles native scrollback)
for (let r = 0; r < Math.min(lines.length, termH - 2); r++) { let out = SYNC_START + CLEAR
out += `\x1b[${r + 2};1H\x1b[${termW}X${lines[r]}\x1b[0m`
// 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 out += SYNC_END
this.writeRaw(out) this.writeRaw(out)

View File

@@ -3,7 +3,7 @@ import { app } from "../lib/state"
import { updateAll, rebuildDisplayRows, applySortMode, updateTabBar } from "../ui/panels" import { updateAll, rebuildDisplayRows, applySortMode, updateTabBar } from "../ui/panels"
import { extractKeyboardInput, extractMouseEvents } from "./parser" import { extractKeyboardInput, extractMouseEvents } from "./parser"
import { switchToGrid, switchToGridTab, createNewGridTab } from "../grid/view-switch" 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 { launchSelections } from "../actions/launcher"
import { loadSessions } from "../data/sessions" import { loadSessions } from "../data/sessions"
import { loadBranches } from "../data/git" import { loadBranches } from "../data/git"
@@ -338,6 +338,27 @@ export async function handleKeypress(key: KeyEvent) {
case "g": { case "g": {
const row = app.displayRows[app.cursor] const row = app.displayRows[app.cursor]
const project = app.projects[row.projectIndex] 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) { if (project.activeSessions > 0) {
const sid = row.type === "session" && project.sessions const sid = row.type === "session" && project.sessions
? project.sessions[row.sessionIndex!]?.id ? project.sessions[row.sessionIndex!]?.id
@@ -380,7 +401,18 @@ export async function handleKeypress(key: KeyEvent) {
case "return": { case "return": {
const hasSelections = app.selectedProjects.size > 0 || app.selectedSessions.size > 0 const hasSelections = app.selectedProjects.size > 0 || app.selectedSessions.size > 0
if (hasSelections) { if (hasSelections) {
if (app.addPaneTargetTabId !== null) {
doAddPane()
} else {
doLaunch() 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 break
} }
if (app.bottomPanelMode === "idle" && app.cachedIdleSessions.length > 0 && app.idleCursor < app.cachedIdleSessions.length) { 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": 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") { if (app.restoreMode === "pending") {
app.restoreMode = null app.restoreMode = null
break break
@@ -468,7 +510,7 @@ export async function handleKeypress(key: KeyEvent) {
} }
updateAll() updateAll()
} catch {} } catch (err) { console.error("[handleKeypress]", err) }
} }
// ─── Grid input ────────────────────────────────────────────────────── // ─── Grid input ──────────────────────────────────────────────────────
@@ -521,8 +563,13 @@ export async function handleGridInput(rawSequence: string): Promise<boolean> {
if (rawSequence === "\x1bn") { handleNextTab(); return true } if (rawSequence === "\x1bn") { handleNextTab(); return true }
if (rawSequence === "\x1bp") { handlePrevTab(); return true } if (rawSequence === "\x1bp") { handlePrevTab(); return true }
// Ctrl+N / Ctrl+P → focus next/prev pane // Ctrl+N → add pane to current tab (enter picker in add-pane mode)
if (rawSequence === "\x0e") { app.directGrid.focusNext(); return true } if (rawSequence === "\x0e") {
app.addPaneTargetTabId = app.directGrid.activeTabId
switchToPicker()
return true
}
// Ctrl+P → focus prev pane
if (rawSequence === "\x10") { app.directGrid.focusPrev(); return true } if (rawSequence === "\x10") { app.directGrid.focusPrev(); return true }
// Ctrl+F → open folder // Ctrl+F → open folder
@@ -585,6 +632,24 @@ export function switchToPicker() {
app.renderer.requestRender() 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 ──────────────────────────────────────────────── // ─── Stdin: grid mode ────────────────────────────────────────────────
function processGridInput(str: string) { function processGridInput(str: string) {
@@ -599,9 +664,9 @@ function processGridInput(str: string) {
for (const me of mouseEvents) { for (const me of mouseEvents) {
if (me.btn === 64) { dg.sendScrollToFocused("up", 3); continue } if (me.btn === 64) { dg.sendScrollToFocused("up", 3); continue }
if (me.btn === 65) { dg.sendScrollToFocused("down", 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) { 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) const btn = dg.checkButtonClick(me.col, me.row)
if (btn?.action === "closetab" && btn.tabId !== undefined) { if (btn?.action === "closetab" && btn.tabId !== undefined) {
const result = dg.requestCloseTab(btn.tabId) const result = dg.requestCloseTab(btn.tabId)

View File

@@ -65,8 +65,10 @@ export function extractKeyboardInput(data: string): string {
i += 3; continue i += 3; continue
} }
// Alt+digit (1-9) and Alt+letter (n, p) — keep as keyboard shortcuts // Alt+key combos: \x1b + printable/control char — pass through to PTY
if ((next >= "1" && next <= "9") || next === "n" || next === "p") { // 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) keyboard += data.slice(i, i + 2)
i += 2; continue 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) // Get screen as lines with embedded ANSI SGR codes (like tmux capture-pane -e)
// When scrollOffset > 0, shows scrollback history mixed with screen content // When scrollOffset > 0, shows scrollback history mixed with screen content
getLines(): string[] { 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 { export function stopCapture(sessionName: string): void {
const state = panes.get(sessionName) const state = panes.get(sessionName)
if (!state) return 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 { export function resizeSession(name: string, width: number, height: number): void {
const session = sessions.get(name) const session = sessions.get(name)
if (!session || !session.alive) return if (!session || !session.alive || session.proc.killed) return
session.width = width session.width = width
session.height = height session.height = height
// Send APC resize command: \x1b_R<rows>;<cols>\x1b\\ // Send APC resize command: \x1b_R<rows>;<cols>\x1b\\
const resizeCmd = `\x1b_R${height};${width}\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 { export function writeToSession(name: string, data: string): void {
const session = sessions.get(name) const session = sessions.get(name)
if (!session || !session.alive) return if (!session || !session.alive || session.proc.killed) return
session.proc.stdin.write(data) try { session.proc.stdin.write(data) } catch {}
} }
export function isAlive(name: string): boolean { export function isAlive(name: string): boolean {