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:
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
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
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user