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