feat: add direct PTY grid with expand/select mode for text copying
Replace tmux-based grid with direct PTY rendering. Add [MAX] button to expand a pane to fullscreen and [SEL] to enable native text selection within the expanded pane. Esc exits select/expanded mode. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
538
src/components/direct-grid.ts
Normal file
538
src/components/direct-grid.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
// Direct grid renderer: bypasses OpenTUI entirely for grid mode.
|
||||
// Draws chrome (borders/titles) and pane content using raw ANSI cursor-addressed writes.
|
||||
// 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 { writeToSession, resizeSession, killSession, type PtySession } from "../pty/session-manager"
|
||||
|
||||
export type PaneStatus = "busy" | "idle" | null
|
||||
|
||||
export interface GridPaneInfo {
|
||||
session: PtySession
|
||||
directPane: DirectPane
|
||||
status: PaneStatus
|
||||
statusSince: number
|
||||
}
|
||||
|
||||
const PROJECT_COLORS = [
|
||||
"#7aa2f7", "#9ece6a", "#e0af68", "#f7768e", "#bb9af7",
|
||||
"#7dcfff", "#ff9e64", "#c0caf5", "#73daca", "#b4f9f8",
|
||||
]
|
||||
|
||||
function getColor(idx: number): string {
|
||||
return PROJECT_COLORS[idx % PROJECT_COLORS.length]!
|
||||
}
|
||||
|
||||
function hexFg(hex: string): string {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return `\x1b[38;2;${r};${g};${b}m`
|
||||
}
|
||||
|
||||
const RESET = "\x1b[0m"
|
||||
const BOLD = "\x1b[1m"
|
||||
const DIM = "\x1b[2m"
|
||||
const WHITE = "\x1b[38;2;255;255;255m"
|
||||
const HIDE_CURSOR = "\x1b[?25l"
|
||||
const SHOW_CURSOR = "\x1b[?25h"
|
||||
const SYNC_START = "\x1b[?2026h"
|
||||
const SYNC_END = "\x1b[?2026l"
|
||||
const CLEAR = "\x1b[2J\x1b[H"
|
||||
|
||||
function fmtElapsed(sinceMs: number): string {
|
||||
if (!sinceMs) return ""
|
||||
const sec = Math.floor((Date.now() - sinceMs) / 1000)
|
||||
if (sec < 1) return "0s"
|
||||
if (sec < 60) return `${sec}s`
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = sec % 60
|
||||
if (m < 60) return `${m}m${s > 0 ? String(s).padStart(2, "0") + "s" : ""}`
|
||||
const h = Math.floor(m / 60)
|
||||
const rm = m % 60
|
||||
return `${h}h${rm > 0 ? String(rm).padStart(2, "0") + "m" : ""}`
|
||||
}
|
||||
|
||||
export class DirectGridRenderer {
|
||||
private panes: GridPaneInfo[] = []
|
||||
private _focusIndex = 0
|
||||
private writeRaw: (s: string) => boolean
|
||||
private flashTimers = new Map<string, ReturnType<typeof setInterval>>()
|
||||
private titleTimer: ReturnType<typeof setInterval> | null = null
|
||||
private running = false
|
||||
private _selectMode = false
|
||||
private _expandedIndex = -1 // -1 = grid view, >=0 = expanded pane index
|
||||
|
||||
constructor(rawWrite: (s: string) => boolean) {
|
||||
this.writeRaw = rawWrite
|
||||
}
|
||||
|
||||
// ─── Lifecycle ─────────────────────────────────────────
|
||||
|
||||
start() {
|
||||
this.running = true
|
||||
this.writeRaw(HIDE_CURSOR + CLEAR)
|
||||
this.drawChrome()
|
||||
this.titleTimer = setInterval(() => this.refreshTitles(), 1000)
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.running = false
|
||||
if (this.titleTimer) { clearInterval(this.titleTimer); this.titleTimer = null }
|
||||
for (const timer of this.flashTimers.values()) clearInterval(timer)
|
||||
this.flashTimers.clear()
|
||||
for (const p of this.panes) {
|
||||
p.directPane.detach()
|
||||
stopCapture(p.session.name)
|
||||
}
|
||||
this.writeRaw(SHOW_CURSOR)
|
||||
}
|
||||
|
||||
// ─── Getters ───────────────────────────────────────────
|
||||
|
||||
get focusIndex() { return this._focusIndex }
|
||||
get paneCount() { return this.panes.length }
|
||||
get focusedPane(): GridPaneInfo | null { return this.panes[this._focusIndex] ?? null }
|
||||
get selectMode() { return this._selectMode }
|
||||
get isExpanded() { return this._expandedIndex >= 0 }
|
||||
|
||||
enterSelectMode() {
|
||||
if (!this.isExpanded) return // Only allow in expanded mode
|
||||
this._selectMode = true
|
||||
this.writeRaw("\x1b[?1000l\x1b[?1006l") // Disable mouse reporting
|
||||
this.writeRaw(SHOW_CURSOR)
|
||||
this.drawChrome()
|
||||
}
|
||||
|
||||
exitSelectMode() {
|
||||
this._selectMode = false
|
||||
this.writeRaw("\x1b[?1000h\x1b[?1006h") // Re-enable mouse reporting
|
||||
this.writeRaw(HIDE_CURSOR)
|
||||
this.drawChrome()
|
||||
}
|
||||
|
||||
expandPane(index?: number) {
|
||||
const idx = index ?? this._focusIndex
|
||||
if (idx < 0 || idx >= this.panes.length) return
|
||||
this._expandedIndex = idx
|
||||
this._focusIndex = idx
|
||||
this.repositionAll()
|
||||
}
|
||||
|
||||
collapsePane() {
|
||||
if (this._selectMode) this.exitSelectMode()
|
||||
this._expandedIndex = -1
|
||||
this.repositionAll()
|
||||
}
|
||||
|
||||
// Check if a click hit a button on the top border. Returns action + pane index.
|
||||
checkButtonClick(col: number, row: number): { action: "max" | "min" | "sel", paneIndex: number } | null {
|
||||
const indicesToCheck = this.isExpanded ? [this._expandedIndex] : this.panes.map((_, i) => i)
|
||||
for (const i of indicesToCheck) {
|
||||
const dp = this.panes[i]!.directPane
|
||||
const bx = dp.screenX - 1
|
||||
const by = dp.screenY - 3
|
||||
const bw = dp.width + 2
|
||||
const btnRow = by
|
||||
|
||||
if (row !== btnRow) continue
|
||||
|
||||
if (this.isExpanded) {
|
||||
// Expanded: buttons are [SEL] and [MIN] at top-right
|
||||
// Layout: ...hz [SEL] hz [MIN] hz tr
|
||||
const minRight = bx + bw - 2
|
||||
const minLeft = minRight - 4
|
||||
if (col >= minLeft && col <= minRight) return { action: "min", paneIndex: i }
|
||||
const selRight = minLeft - 2
|
||||
const selLeft = selRight - 4
|
||||
if (col >= selLeft && col <= selRight) return { action: "sel", paneIndex: i }
|
||||
} else {
|
||||
// Grid: button is [MAX] at top-right
|
||||
const btnLeft = bx + bw - 7
|
||||
const btnRight = bx + bw - 3
|
||||
if (col >= btnLeft && col <= btnRight) return { action: "max", paneIndex: i }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Pane management ───────────────────────────────────
|
||||
|
||||
async addPane(session: PtySession): Promise<GridPaneInfo> {
|
||||
const regions = this.calcPaneRegions(this.panes.length + 1)
|
||||
const idx = this.panes.length
|
||||
const region = regions[idx]!
|
||||
|
||||
const dp = new DirectPane(region.screenX, region.screenY, region.contentW, region.contentH)
|
||||
const info: GridPaneInfo = { session, directPane: dp, status: null, statusSince: 0 }
|
||||
this.panes.push(info)
|
||||
|
||||
// Resize PTY to match content area
|
||||
resizeSession(session.name, region.contentW, region.contentH)
|
||||
|
||||
// Start capture (reads PTY stdout and pushes frames)
|
||||
startCapture(session)
|
||||
|
||||
// Subscribe to push frames (must set callback AFTER attach, since attach calls detach which nulls onFrame)
|
||||
dp.attach(session.name)
|
||||
dp.onFrame = (lines) => {
|
||||
if (!this.running) return
|
||||
this.drawPane(idx, lines)
|
||||
}
|
||||
|
||||
// Reposition all existing panes
|
||||
this.repositionAll()
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
removePane(sessionName: string) {
|
||||
const idx = this.panes.findIndex(p => p.session.name === sessionName)
|
||||
if (idx < 0) return
|
||||
|
||||
const pane = this.panes[idx]!
|
||||
pane.directPane.detach()
|
||||
stopCapture(pane.session.name)
|
||||
killSession(pane.session.name)
|
||||
this.clearFlash(sessionName)
|
||||
this.panes.splice(idx, 1)
|
||||
|
||||
if (this._focusIndex >= this.panes.length) {
|
||||
this._focusIndex = Math.max(0, this.panes.length - 1)
|
||||
}
|
||||
|
||||
this.repositionAll()
|
||||
}
|
||||
|
||||
// ─── Focus ─────────────────────────────────────────────
|
||||
|
||||
setFocus(index: number) {
|
||||
if (index < 0 || index >= this.panes.length) return
|
||||
this._focusIndex = index
|
||||
this.drawChrome()
|
||||
}
|
||||
|
||||
focusNext() {
|
||||
if (this.panes.length === 0) return
|
||||
this.setFocus((this._focusIndex + 1) % this.panes.length)
|
||||
}
|
||||
|
||||
focusPrev() {
|
||||
if (this.panes.length === 0) return
|
||||
this.setFocus((this._focusIndex - 1 + this.panes.length) % this.panes.length)
|
||||
}
|
||||
|
||||
focusByDirection(dir: "up" | "down" | "left" | "right") {
|
||||
const n = this.panes.length
|
||||
if (n <= 1) return
|
||||
const { cols } = this.calcGrid(n)
|
||||
const rows = Math.ceil(n / cols)
|
||||
const curCol = this._focusIndex % cols
|
||||
const curRow = Math.floor(this._focusIndex / cols)
|
||||
let nc = curCol, nr = curRow
|
||||
switch (dir) {
|
||||
case "left": nc = (curCol - 1 + cols) % cols; break
|
||||
case "right": nc = (curCol + 1) % cols; break
|
||||
case "up": nr = (curRow - 1 + rows) % rows; break
|
||||
case "down": nr = (curRow + 1) % rows; break
|
||||
}
|
||||
const idx = nr * cols + nc
|
||||
if (idx >= 0 && idx < n) this.setFocus(idx)
|
||||
}
|
||||
|
||||
focusByClick(col: number, row: number): boolean {
|
||||
const n = this.panes.length
|
||||
if (n === 0) return false
|
||||
const termW = process.stdout.columns || 120
|
||||
const termH = process.stdout.rows || 40
|
||||
const { cols } = this.calcGrid(n)
|
||||
const rows = Math.ceil(n / cols)
|
||||
const cellW = Math.floor(termW / cols)
|
||||
const cellH = Math.floor((termH - 2) / rows)
|
||||
const gc = Math.floor((col - 1) / cellW)
|
||||
const gr = Math.floor((row - 2) / cellH)
|
||||
const idx = gr * cols + gc
|
||||
if (idx >= 0 && idx < n) {
|
||||
this.setFocus(idx)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── Chrome ────────────────────────────────────────────
|
||||
|
||||
drawChrome() {
|
||||
if (!this.running) return
|
||||
const termW = process.stdout.columns || 120
|
||||
const termH = process.stdout.rows || 40
|
||||
|
||||
let out = SYNC_START
|
||||
|
||||
// Header (row 1)
|
||||
const n = this.panes.length
|
||||
const fi = this._focusIndex + 1
|
||||
let headerLeft: string, headerRight: string
|
||||
if (this._selectMode) {
|
||||
headerLeft = ` ${BOLD}cladm grid${RESET} — ${hexFg("#9ece6a")}${BOLD}SELECT MODE${RESET}`
|
||||
headerRight = `${DIM}drag to select │ cmd+c copy │ ${BOLD}Esc${RESET}${DIM} exit select${RESET}`
|
||||
} else if (this.isExpanded) {
|
||||
headerLeft = ` ${BOLD}cladm grid${RESET} — ${hexFg("#7dcfff")}${BOLD}EXPANDED${RESET} │ ${fi}/${n}`
|
||||
headerRight = `${DIM}click ${BOLD}[SEL]${RESET}${DIM} select text │ click ${BOLD}[MIN]${RESET}${DIM} restore │ ctrl+\` picker${RESET}`
|
||||
} else {
|
||||
headerLeft = ` ${BOLD}cladm grid${RESET} — ${n} sessions │ focus: ${fi}/${n}`
|
||||
headerRight = `${DIM}shift+arrows nav │ scroll/pgup/dn │ click ${BOLD}[MAX]${RESET}${DIM} expand │ ctrl+\` picker │ ctrl+w close${RESET}`
|
||||
}
|
||||
out += `\x1b[1;1H\x1b[${termW}X${headerLeft} ${headerRight}`
|
||||
|
||||
// Pane borders + titles
|
||||
if (this.isExpanded) {
|
||||
out += this.drawPaneBorder(this._expandedIndex)
|
||||
} else {
|
||||
for (let i = 0; i < this.panes.length; i++) {
|
||||
out += this.drawPaneBorder(i)
|
||||
}
|
||||
}
|
||||
|
||||
// Footer (last row)
|
||||
const pane = this.focusedPane
|
||||
if (this._selectMode) {
|
||||
out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg("#9ece6a")}${BOLD}SELECT MODE${RESET} ${DIM}drag to select text │ cmd+c to copy │ press ${BOLD}Esc${RESET}${DIM} to exit${RESET}`
|
||||
} else if (this.isExpanded && pane) {
|
||||
const color = getColor(pane.session.colorIndex)
|
||||
out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg(color)}▸${RESET} ${BOLD}${pane.session.projectName}${RESET} ${DIM}expanded │ Esc or [MIN] to restore grid${RESET}`
|
||||
} else if (pane) {
|
||||
const color = getColor(pane.session.colorIndex)
|
||||
const sid = pane.session.sessionId ? ` ${DIM}#${pane.session.sessionId.slice(0, 8)}${RESET}` : ""
|
||||
out += `\x1b[${termH};1H\x1b[${termW}X ${hexFg(color)}▸${RESET} ${BOLD}${pane.session.projectName}${RESET}${sid} ${DIM}all input goes to focused pane${RESET}`
|
||||
} else {
|
||||
out += `\x1b[${termH};1H\x1b[${termW}X ${DIM}No sessions. Press ctrl+\` to return to picker.${RESET}`
|
||||
}
|
||||
|
||||
out += SYNC_END
|
||||
this.writeRaw(out)
|
||||
}
|
||||
|
||||
private drawPaneBorder(index: number): string {
|
||||
const pane = this.panes[index]!
|
||||
const dp = pane.directPane
|
||||
const isFocused = index === this._focusIndex
|
||||
const isFlashing = this.flashTimers.has(pane.session.name)
|
||||
|
||||
const color = getColor(pane.session.colorIndex)
|
||||
let borderColor: string
|
||||
if (isFocused) borderColor = WHITE
|
||||
else if (isFlashing) borderColor = hexFg("#ff9e64")
|
||||
else borderColor = hexFg(color)
|
||||
|
||||
const tl = isFocused ? "┏" : "╭"
|
||||
const tr = isFocused ? "┓" : "╮"
|
||||
const bl = isFocused ? "┗" : "╰"
|
||||
const br = isFocused ? "┛" : "╯"
|
||||
const hz = isFocused ? "━" : "─"
|
||||
const vt = isFocused ? "┃" : "│"
|
||||
|
||||
const bx = dp.screenX - 1
|
||||
const by = dp.screenY - 3
|
||||
const bw = dp.width + 2
|
||||
const bh = dp.height + 4
|
||||
|
||||
let out = ""
|
||||
|
||||
// Top border with buttons
|
||||
let btnSection: string
|
||||
let btnVisibleLen: number
|
||||
if (this.isExpanded) {
|
||||
// Expanded: [SEL] [MIN] at right
|
||||
const selColor = this._selectMode ? `${hexFg("#9ece6a")}${BOLD}` : `${DIM}`
|
||||
btnSection = `${RESET}${selColor}[SEL]${RESET}${borderColor}${hz}${RESET}${hexFg("#7dcfff")}[MIN]${RESET}${borderColor}`
|
||||
btnVisibleLen = 5 + 1 + 5 // [SEL] + hz + [MIN]
|
||||
} else {
|
||||
// Grid: [MAX] at right
|
||||
btnSection = `${RESET}${DIM}[MAX]${RESET}${borderColor}`
|
||||
btnVisibleLen = 5 // [MAX]
|
||||
}
|
||||
const hzFill = Math.max(0, bw - 2 - btnVisibleLen - 1) // -2 corners, -1 trailing hz
|
||||
out += `\x1b[${by};${bx}H${borderColor}${tl}${hz.repeat(hzFill)}${btnSection}${hz}${tr}${RESET}`
|
||||
|
||||
// Title row
|
||||
const nameColor = hexFg(color)
|
||||
const name = pane.session.projectName
|
||||
const elapsed = pane.statusSince ? fmtElapsed(pane.statusSince) : ""
|
||||
const scrollOff = getScrollOffset(pane.session.name)
|
||||
const scrollTag = scrollOff > 0 ? ` ${hexFg("#ff9e64")}[SCROLL +${scrollOff}]${RESET}` : ""
|
||||
let titleContent: string
|
||||
if (pane.status === "idle") {
|
||||
titleContent = ` ${nameColor}${BOLD}${name}${RESET} ${hexFg("#e0af68")}IDLE${RESET} ${DIM}${elapsed}${RESET}${scrollTag}`
|
||||
} else if (pane.status === "busy") {
|
||||
titleContent = ` ${nameColor}${BOLD}${name}${RESET} ${hexFg("#9ece6a")}RUNNING${RESET} ${DIM}${elapsed}${RESET}${scrollTag}`
|
||||
} else {
|
||||
const sid = pane.session.sessionId ? ` ${DIM}#${pane.session.sessionId.slice(0, 8)}${RESET}` : ""
|
||||
titleContent = ` ${nameColor}${BOLD}${name}${RESET}${sid}${scrollTag}`
|
||||
}
|
||||
out += `\x1b[${by + 1};${bx}H${borderColor}${vt}${RESET}\x1b[${bw - 2}X${titleContent}`
|
||||
out += `\x1b[${by + 1};${bx + bw - 1}H${borderColor}${vt}${RESET}`
|
||||
|
||||
// Subtitle row
|
||||
out += `\x1b[${by + 2};${bx}H${borderColor}${vt}${RESET}\x1b[${bw - 2}X ${DIM}${pane.session.projectPath}${RESET}`
|
||||
out += `\x1b[${by + 2};${bx + bw - 1}H${borderColor}${vt}${RESET}`
|
||||
|
||||
// Side borders for content rows
|
||||
for (let r = 0; r < dp.height; r++) {
|
||||
out += `\x1b[${dp.screenY + r};${bx}H${borderColor}${vt}${RESET}`
|
||||
out += `\x1b[${dp.screenY + r};${bx + bw - 1}H${borderColor}${vt}${RESET}`
|
||||
}
|
||||
|
||||
// Bottom border
|
||||
out += `\x1b[${by + bh - 1};${bx}H${borderColor}${bl}${hz.repeat(bw - 2)}${br}${RESET}`
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// ─── Content rendering ─────────────────────────────────
|
||||
|
||||
private drawPane(index: number, lines: string[]) {
|
||||
// In expanded mode, only draw the expanded pane
|
||||
if (this.isExpanded && index !== this._expandedIndex) return
|
||||
const pane = this.panes[index]
|
||||
if (!pane) return
|
||||
const frame = pane.directPane.buildFrame(lines)
|
||||
this.writeRaw(SYNC_START + frame + SYNC_END)
|
||||
}
|
||||
|
||||
// ─── Input ─────────────────────────────────────────────
|
||||
|
||||
sendInputToFocused(rawSequence: string) {
|
||||
const pane = this.focusedPane
|
||||
if (!pane) return
|
||||
// Reset scroll offset when user types (back to live view)
|
||||
const offset = getScrollOffset(pane.session.name)
|
||||
if (offset > 0) {
|
||||
scrollPane(pane.session.name, "down", offset)
|
||||
this.drawChrome()
|
||||
}
|
||||
writeToSession(pane.session.name, rawSequence)
|
||||
}
|
||||
|
||||
sendScrollToFocused(direction: "up" | "down", lines = 5) {
|
||||
const pane = this.focusedPane
|
||||
if (!pane) return
|
||||
const offset = scrollPane(pane.session.name, direction, lines)
|
||||
// Update title to show scroll indicator
|
||||
this.drawChrome()
|
||||
}
|
||||
|
||||
// ─── Status ────────────────────────────────────────────
|
||||
|
||||
markIdle(sessionName: string) {
|
||||
const pane = this.panes.find(p => p.session.name === sessionName)
|
||||
if (!pane) return
|
||||
if (pane.status !== "idle") { pane.status = "idle"; pane.statusSince = Date.now() }
|
||||
this.startFlash(sessionName)
|
||||
this.drawChrome()
|
||||
}
|
||||
|
||||
markBusy(sessionName: string) {
|
||||
const pane = this.panes.find(p => p.session.name === sessionName)
|
||||
if (!pane) return
|
||||
if (pane.status !== "busy") { pane.status = "busy"; pane.statusSince = Date.now() }
|
||||
this.clearFlash(sessionName)
|
||||
this.drawChrome()
|
||||
}
|
||||
|
||||
clearMark(sessionName: string) {
|
||||
const pane = this.panes.find(p => p.session.name === sessionName)
|
||||
if (!pane) return
|
||||
pane.status = null; pane.statusSince = 0
|
||||
this.clearFlash(sessionName)
|
||||
this.drawChrome()
|
||||
}
|
||||
|
||||
startFlash(sessionName: string) {
|
||||
if (this.flashTimers.has(sessionName)) return
|
||||
const timer = setInterval(() => this.drawChrome(), 400)
|
||||
this.flashTimers.set(sessionName, timer)
|
||||
}
|
||||
|
||||
clearFlash(sessionName: string) {
|
||||
const timer = this.flashTimers.get(sessionName)
|
||||
if (timer) { clearInterval(timer); this.flashTimers.delete(sessionName) }
|
||||
}
|
||||
|
||||
// ─── Layout ────────────────────────────────────────────
|
||||
|
||||
private calcGrid(n?: number): { cols: number, rows: number } {
|
||||
const count = n ?? this.panes.length
|
||||
const cols = count <= 1 ? 1 : count <= 2 ? 2 : count <= 4 ? 2 : count <= 6 ? 3 : count <= 9 ? 3 : 4
|
||||
return { cols, rows: Math.ceil(count / cols) }
|
||||
}
|
||||
|
||||
private calcPaneRegions(count?: number): { screenX: number, screenY: number, contentW: number, contentH: number }[] {
|
||||
const n = count ?? this.panes.length
|
||||
const termW = process.stdout.columns || 120
|
||||
const termH = process.stdout.rows || 40
|
||||
const { cols, rows } = this.calcGrid(n)
|
||||
const cellW = Math.floor(termW / cols)
|
||||
const cellH = Math.floor((termH - 2) / rows)
|
||||
|
||||
const regions: { screenX: number, screenY: number, contentW: number, contentH: number }[] = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const gc = i % cols
|
||||
const gr = Math.floor(i / cols)
|
||||
const contentW = cellW - 2
|
||||
const contentH = cellH - 4
|
||||
const screenX = gc * cellW + 2
|
||||
const screenY = 2 + gr * cellH + 3
|
||||
regions.push({
|
||||
screenX,
|
||||
screenY,
|
||||
contentW: Math.max(contentW, 10),
|
||||
contentH: Math.max(contentH, 2),
|
||||
})
|
||||
}
|
||||
return regions
|
||||
}
|
||||
|
||||
repositionAll() {
|
||||
if (this.isExpanded) {
|
||||
// Expanded: give the expanded pane full screen area
|
||||
const termW = process.stdout.columns || 120
|
||||
const termH = process.stdout.rows || 40
|
||||
const contentW = termW - 2
|
||||
const contentH = termH - 2 - 4 // -2 header/footer, -4 border chrome
|
||||
const pane = this.panes[this._expandedIndex]!
|
||||
pane.directPane.reposition(2, 5, Math.max(contentW, 10), Math.max(contentH, 2))
|
||||
resizeSession(pane.session.name, Math.max(contentW, 10), Math.max(contentH, 2))
|
||||
resizeCapture(pane.session.name, Math.max(contentW, 10), Math.max(contentH, 2))
|
||||
resetHash(`dp_${pane.session.name}`)
|
||||
} else {
|
||||
const regions = this.calcPaneRegions()
|
||||
for (let i = 0; i < this.panes.length; i++) {
|
||||
const pane = this.panes[i]!
|
||||
const region = regions[i]!
|
||||
pane.directPane.reposition(region.screenX, region.screenY, region.contentW, region.contentH)
|
||||
resizeSession(pane.session.name, region.contentW, region.contentH)
|
||||
resizeCapture(pane.session.name, region.contentW, region.contentH)
|
||||
resetHash(`dp_${pane.session.name}`)
|
||||
}
|
||||
}
|
||||
if (this.running) {
|
||||
this.writeRaw(CLEAR)
|
||||
this.drawChrome()
|
||||
}
|
||||
}
|
||||
|
||||
private refreshTitles() {
|
||||
let needsDraw = false
|
||||
for (const pane of this.panes) {
|
||||
if (pane.status && pane.statusSince) needsDraw = true
|
||||
}
|
||||
if (needsDraw) this.drawChrome()
|
||||
}
|
||||
|
||||
destroyAll() {
|
||||
this.stop()
|
||||
this.panes = []
|
||||
this._focusIndex = 0
|
||||
}
|
||||
}
|
||||
74
src/components/direct-pane.ts
Normal file
74
src/components/direct-pane.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
// Lightweight terminal pane: writes raw ANSI from PTY capture directly to stdout.
|
||||
// No parsing, no FrameBuffer, no OpenTUI. Just cursor-addressed raw lines.
|
||||
|
||||
import { onFrame, hasChanged, resetHash, type CaptureResult } from "../pty/capture"
|
||||
|
||||
export class DirectPane {
|
||||
screenX: number // 1-based screen column of content area
|
||||
screenY: number // 1-based screen row of content area
|
||||
width: number // content columns
|
||||
height: number // content rows
|
||||
sessionName = ""
|
||||
|
||||
private unsub: (() => void) | null = null
|
||||
|
||||
// Set by DirectGridRenderer to receive frame updates
|
||||
onFrame: ((lines: string[], pane: DirectPane) => void) | null = null
|
||||
|
||||
constructor(x: number, y: number, w: number, h: number) {
|
||||
this.screenX = x
|
||||
this.screenY = y
|
||||
this.width = w
|
||||
this.height = h
|
||||
}
|
||||
|
||||
attach(sessionName: string) {
|
||||
this.detach()
|
||||
this.sessionName = sessionName
|
||||
resetHash(`dp_${sessionName}`)
|
||||
this.unsub = onFrame(sessionName, (result) => {
|
||||
if (!hasChanged(result.lines, `dp_${sessionName}`)) return
|
||||
if (this.onFrame) this.onFrame(result.lines, this)
|
||||
})
|
||||
}
|
||||
|
||||
detach() {
|
||||
if (this.unsub) { this.unsub(); this.unsub = null }
|
||||
if (this.sessionName) resetHash(`dp_${this.sessionName}`)
|
||||
this.sessionName = ""
|
||||
this.onFrame = null
|
||||
}
|
||||
|
||||
reposition(x: number, y: number, w: number, h: number) {
|
||||
this.screenX = x
|
||||
this.screenY = y
|
||||
this.width = w
|
||||
this.height = h
|
||||
}
|
||||
|
||||
// Build cursor-addressed ANSI output for this pane's content.
|
||||
// Returns a string ready for stdout.write(). No allocations beyond the string.
|
||||
buildFrame(lines: string[]): string {
|
||||
let out = ""
|
||||
const x = this.screenX
|
||||
const y = this.screenY
|
||||
const w = this.width
|
||||
const h = this.height
|
||||
|
||||
for (let row = 0; row < h; row++) {
|
||||
// Position cursor at start of this row
|
||||
out += `\x1b[${y + row};${x}H`
|
||||
// Erase w characters (clears old content)
|
||||
out += `\x1b[${w}X`
|
||||
|
||||
if (row < lines.length) {
|
||||
// Write raw ANSI line from PTY (already correct width)
|
||||
out += lines[row]
|
||||
// Reset SGR to prevent color bleed into border
|
||||
out += "\x1b[0m"
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,31 @@ import {
|
||||
} from "@opentui/core"
|
||||
import { TerminalView, getProjectColor } from "./terminal-view"
|
||||
import type { TmuxSession } from "../tmux/session-manager"
|
||||
import { sendKeys } from "../tmux/input-bridge"
|
||||
import { sendKeys, sendMouseEvent } from "../tmux/input-bridge"
|
||||
|
||||
export type PaneStatus = "busy" | "idle" | null
|
||||
|
||||
export interface GridPane {
|
||||
session: TmuxSession
|
||||
termView: TerminalView
|
||||
borderBox: BoxRenderable
|
||||
titleText: TextRenderable
|
||||
subtitleText: TextRenderable
|
||||
status: PaneStatus
|
||||
statusSince: number // Date.now() when status was set
|
||||
}
|
||||
|
||||
function fmtElapsed(sinceMs: number): string {
|
||||
if (!sinceMs) return ""
|
||||
const sec = Math.floor((Date.now() - sinceMs) / 1000)
|
||||
if (sec < 1) return "0s"
|
||||
if (sec < 60) return `${sec}s`
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = sec % 60
|
||||
if (m < 60) return `${m}m${s > 0 ? String(s).padStart(2, "0") + "s" : ""}`
|
||||
const h = Math.floor(m / 60)
|
||||
const rm = m % 60
|
||||
return `${h}h${rm > 0 ? String(rm).padStart(2, "0") + "m" : ""}`
|
||||
}
|
||||
|
||||
export class SessionGrid {
|
||||
@@ -25,17 +43,19 @@ export class SessionGrid {
|
||||
private panes: GridPane[] = []
|
||||
private _focusIndex = 0
|
||||
private flashTimers = new Map<string, ReturnType<typeof setInterval>>()
|
||||
private titleTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
constructor(renderer: CliRenderer, container: BoxRenderable) {
|
||||
this.renderer = renderer
|
||||
this.container = container
|
||||
this.titleTimer = setInterval(() => this.refreshTitles(), 1000)
|
||||
}
|
||||
|
||||
get focusIndex() { return this._focusIndex }
|
||||
get paneCount() { return this.panes.length }
|
||||
get focusedPane(): GridPane | null { return this.panes[this._focusIndex] ?? null }
|
||||
|
||||
addSession(session: TmuxSession): GridPane {
|
||||
addSession(session: TmuxSession, subtitle?: ReturnType<typeof t>): GridPane {
|
||||
const color = getProjectColor(session.colorIndex)
|
||||
const colorRGBA = RGBA.fromHex(color)
|
||||
|
||||
@@ -55,18 +75,27 @@ export class SessionGrid {
|
||||
})
|
||||
titleText.content = t` ${bold(fg(color)(session.projectName))}${session.sessionId ? dim(` #${session.sessionId.slice(0, 8)}`) : ""}`
|
||||
|
||||
// Calculate pane size (leave room for border + title)
|
||||
const subtitleText = new TextRenderable(this.renderer, {
|
||||
width: "100%",
|
||||
height: 1,
|
||||
flexShrink: 0,
|
||||
})
|
||||
if (subtitle) subtitleText.content = subtitle
|
||||
else subtitleText.content = t` ${dim("...")}`
|
||||
|
||||
// Calculate pane size (leave room for border + title + subtitle)
|
||||
const dims = this.calcPaneDims()
|
||||
const termView = new TerminalView(this.renderer, {
|
||||
width: Math.max(dims.w - 2, 10),
|
||||
height: Math.max(dims.h - 3, 4),
|
||||
height: Math.max(dims.h - 4, 4),
|
||||
})
|
||||
|
||||
borderBox.add(titleText)
|
||||
borderBox.add(subtitleText)
|
||||
borderBox.add(termView)
|
||||
this.container.add(borderBox)
|
||||
|
||||
const pane: GridPane = { session, termView, borderBox, titleText }
|
||||
const pane: GridPane = { session, termView, borderBox, titleText, subtitleText, status: null, statusSince: 0 }
|
||||
this.panes.push(pane)
|
||||
|
||||
termView.attach(session)
|
||||
@@ -114,10 +143,110 @@ export class SessionGrid {
|
||||
}
|
||||
}
|
||||
|
||||
async sendInputToFocused(rawSequence: string) {
|
||||
flashFocused() {
|
||||
const pane = this.focusedPane
|
||||
if (!pane) return
|
||||
await sendKeys(pane.session.name, rawSequence)
|
||||
const flashColor = RGBA.fromHex("#7dcfff")
|
||||
pane.borderBox.borderColor = flashColor
|
||||
this.renderer.requestRender()
|
||||
setTimeout(() => {
|
||||
// Restore to heavy white (focused state)
|
||||
pane.borderBox.borderColor = RGBA.fromHex("#ffffff")
|
||||
this.renderer.requestRender()
|
||||
}, 150)
|
||||
}
|
||||
|
||||
focusByDirection(dir: "up" | "down" | "left" | "right") {
|
||||
const n = this.panes.length
|
||||
if (n <= 1) return
|
||||
const cols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4
|
||||
const rows = Math.ceil(n / cols)
|
||||
const curCol = this._focusIndex % cols
|
||||
const curRow = Math.floor(this._focusIndex / cols)
|
||||
|
||||
let newCol = curCol
|
||||
let newRow = curRow
|
||||
switch (dir) {
|
||||
case "left": newCol = (curCol - 1 + cols) % cols; break
|
||||
case "right": newCol = (curCol + 1) % cols; break
|
||||
case "up": newRow = (curRow - 1 + rows) % rows; break
|
||||
case "down": newRow = (curRow + 1) % rows; break
|
||||
}
|
||||
const idx = newRow * cols + newCol
|
||||
if (idx >= 0 && idx < n) {
|
||||
this._focusIndex = idx
|
||||
this.updateBorders()
|
||||
}
|
||||
}
|
||||
|
||||
focusByClick(col: number, row: number): boolean {
|
||||
const n = this.panes.length
|
||||
if (n === 0) return false
|
||||
const termW = process.stdout.columns || 120
|
||||
const termH = process.stdout.rows || 40
|
||||
const cols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4
|
||||
const rows = Math.ceil(n / cols)
|
||||
const cellW = Math.floor(termW / cols)
|
||||
const cellH = Math.floor((termH - 2) / rows) // -2 for header+footer
|
||||
const gridCol = Math.floor(col / cellW)
|
||||
const gridRow = Math.floor((row - 1) / cellH) // -1 for header line
|
||||
const idx = gridRow * cols + gridCol
|
||||
if (idx >= 0 && idx < n) {
|
||||
this._focusIndex = idx
|
||||
this.updateBorders()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
sendInputToFocused(rawSequence: string) {
|
||||
const pane = this.focusedPane
|
||||
if (!pane) return
|
||||
sendKeys(pane.session.name, rawSequence)
|
||||
pane.termView.nudge()
|
||||
}
|
||||
|
||||
// Hit-test: map absolute screen coords to pane index + relative terminal coords
|
||||
hitTest(absCol: number, absRow: number): { index: number, relX: number, relY: number } | null {
|
||||
const n = this.panes.length
|
||||
if (n === 0) return null
|
||||
const termW = process.stdout.columns || 120
|
||||
const termH = process.stdout.rows || 40
|
||||
const gridCols = n <= 1 ? 1 : n <= 2 ? 2 : n <= 4 ? 2 : n <= 6 ? 3 : n <= 9 ? 3 : 4
|
||||
const gridRows = Math.ceil(n / gridCols)
|
||||
const cellW = Math.floor(termW / gridCols)
|
||||
const cellH = Math.floor((termH - 2) / gridRows)
|
||||
|
||||
// Convert 1-based screen coords to grid cell
|
||||
const gc = Math.floor((absCol - 1) / cellW)
|
||||
const gr = Math.floor((absRow - 2) / cellH) // row 2 is first grid row (row 1 = header)
|
||||
if (gc < 0 || gc >= gridCols || gr < 0 || gr >= gridRows) return null
|
||||
|
||||
const idx = gr * gridCols + gc
|
||||
if (idx < 0 || idx >= n) return null
|
||||
|
||||
// Terminal content within cell: after left border(1) + after top border(1)+title(1)+subtitle(1)
|
||||
const termStartX = gc * cellW + 2 // 1-based + left border
|
||||
const termStartY = 2 + gr * cellH + 3 // header(1) + top border(1) + title(1) + subtitle(1)
|
||||
|
||||
return {
|
||||
index: idx,
|
||||
relX: absCol - termStartX + 1, // 1-based for tmux
|
||||
relY: absRow - termStartY + 1,
|
||||
}
|
||||
}
|
||||
|
||||
// Forward mouse event to the focused tmux pane with correct relative coordinates
|
||||
sendMouseToFocused(absCol: number, absRow: number, btn: number, release: boolean) {
|
||||
const pane = this.focusedPane
|
||||
if (!pane) return
|
||||
|
||||
const hit = this.hitTest(absCol, absRow)
|
||||
if (!hit || hit.index !== this._focusIndex) return
|
||||
if (hit.relX < 1 || hit.relY < 1) return
|
||||
if (hit.relX > pane.session.width || hit.relY > pane.session.height) return
|
||||
|
||||
sendMouseEvent(pane.session.name, hit.relX, hit.relY, btn, release)
|
||||
}
|
||||
|
||||
// Flash a pane's border to draw attention (e.g., when session goes idle)
|
||||
@@ -159,34 +288,69 @@ export class SessionGrid {
|
||||
markIdle(sessionName: string) {
|
||||
const pane = this.panes.find(p => p.session.name === sessionName)
|
||||
if (!pane) return
|
||||
if (pane.status !== "idle") {
|
||||
pane.status = "idle"
|
||||
pane.statusSince = Date.now()
|
||||
}
|
||||
this.startFlash(sessionName)
|
||||
pane.titleText.content = t` ${bold(fg(getProjectColor(pane.session.colorIndex))(pane.session.projectName))} ${fg("#e0af68")("NEEDS INPUT")}`
|
||||
this.renderer.requestRender()
|
||||
this.renderPaneTitle(pane)
|
||||
}
|
||||
|
||||
markBusy(sessionName: string) {
|
||||
const pane = this.panes.find(p => p.session.name === sessionName)
|
||||
if (!pane) return
|
||||
if (pane.status !== "busy") {
|
||||
pane.status = "busy"
|
||||
pane.statusSince = Date.now()
|
||||
}
|
||||
this.clearFlash(sessionName)
|
||||
pane.titleText.content = t` ${bold(fg(getProjectColor(pane.session.colorIndex))(pane.session.projectName))} ${fg("#9ece6a")("RUNNING")}`
|
||||
this.renderer.requestRender()
|
||||
this.renderPaneTitle(pane)
|
||||
}
|
||||
|
||||
clearMark(sessionName: string) {
|
||||
const pane = this.panes.find(p => p.session.name === sessionName)
|
||||
if (!pane) return
|
||||
pane.status = null
|
||||
pane.statusSince = 0
|
||||
this.clearFlash(sessionName)
|
||||
this.renderPaneTitle(pane)
|
||||
}
|
||||
|
||||
private renderPaneTitle(pane: GridPane) {
|
||||
const color = getProjectColor(pane.session.colorIndex)
|
||||
pane.titleText.content = t` ${bold(fg(color)(pane.session.projectName))}${pane.session.sessionId ? dim(` #${pane.session.sessionId.slice(0, 8)}`) : ""}`
|
||||
const name = bold(fg(color)(pane.session.projectName))
|
||||
const elapsed = pane.statusSince ? fmtElapsed(pane.statusSince) : ""
|
||||
|
||||
if (pane.status === "idle") {
|
||||
pane.titleText.content = t` ${name} ${fg("#e0af68")("IDLE")} ${dim(elapsed)}`
|
||||
} else if (pane.status === "busy") {
|
||||
pane.titleText.content = t` ${name} ${fg("#9ece6a")("RUNNING")} ${dim(elapsed)}`
|
||||
} else {
|
||||
pane.titleText.content = t` ${name}${pane.session.sessionId ? dim(` #${pane.session.sessionId.slice(0, 8)}`) : ""}`
|
||||
}
|
||||
this.renderer.requestRender()
|
||||
}
|
||||
|
||||
private refreshTitles() {
|
||||
let needsRender = false
|
||||
for (const pane of this.panes) {
|
||||
if (pane.status && pane.statusSince) {
|
||||
this.renderPaneTitle(pane)
|
||||
needsRender = true
|
||||
}
|
||||
}
|
||||
if (needsRender) this.renderer.requestRender()
|
||||
}
|
||||
|
||||
private updateBorders() {
|
||||
for (let i = 0; i < this.panes.length; i++) {
|
||||
const pane = this.panes[i]
|
||||
const isFocused = i === this._focusIndex
|
||||
const color = getProjectColor(pane.session.colorIndex)
|
||||
|
||||
// Update terminal view focus state (controls poll rate)
|
||||
pane.termView.focused = isFocused
|
||||
|
||||
// Focused pane gets brighter border, others get dimmer
|
||||
if (isFocused) {
|
||||
pane.borderBox.borderColor = RGBA.fromHex("#ffffff")
|
||||
@@ -241,6 +405,7 @@ export class SessionGrid {
|
||||
}
|
||||
|
||||
destroyAll() {
|
||||
if (this.titleTimer) { clearInterval(this.titleTimer); this.titleTimer = null }
|
||||
for (const timer of this.flashTimers.values()) clearInterval(timer)
|
||||
this.flashTimers.clear()
|
||||
for (const pane of this.panes) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
RGBA,
|
||||
TextAttributes,
|
||||
} from "@opentui/core"
|
||||
import { capturePane, hasChanged, resetHash } from "../tmux/capture"
|
||||
import { startCapture, stopCapture, setCaptureRate, onFrame, hasChanged, resetHash, type CaptureResult } from "../tmux/capture"
|
||||
import { parseAnsiFrame, type ParsedFrame } from "../tmux/ansi-parser"
|
||||
import type { TmuxSession } from "../tmux/session-manager"
|
||||
|
||||
@@ -28,34 +28,52 @@ export function getProjectColor(colorIndex: number): string {
|
||||
return PROJECT_COLORS[colorIndex % PROJECT_COLORS.length]
|
||||
}
|
||||
|
||||
// Push-based terminal view: no poll timers.
|
||||
// Subscribes to capture stream and renders only when content changes.
|
||||
export class TerminalView extends FrameBufferRenderable {
|
||||
session: TmuxSession | null = null
|
||||
private pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
private unsubCapture: (() => void) | null = null
|
||||
private lastFrame: ParsedFrame | null = null
|
||||
private _focused = false
|
||||
private _flashUntil = 0 // timestamp until which border flashes
|
||||
private _flashUntil = 0
|
||||
private _idleSince = 0
|
||||
private _frameDirty = false
|
||||
|
||||
constructor(ctx: RenderContext, options: FrameBufferOptions) {
|
||||
super(ctx, options)
|
||||
}
|
||||
|
||||
get focused() { return this._focused }
|
||||
set focused(v: boolean) { this._focused = v }
|
||||
set focused(v: boolean) {
|
||||
if (this._focused === v) return
|
||||
this._focused = v
|
||||
// Focused pane captures at ~60fps, unfocused at ~5fps
|
||||
if (this.session) {
|
||||
setCaptureRate(this.session.name, v ? 16 : 200)
|
||||
}
|
||||
}
|
||||
|
||||
get idleSince() { return this._idleSince }
|
||||
|
||||
attach(session: TmuxSession) {
|
||||
this.detach()
|
||||
this.session = session
|
||||
resetHash()
|
||||
this.startPolling()
|
||||
resetHash(session.name)
|
||||
const captureMs = this._focused ? 16 : 200
|
||||
startCapture(session.name, captureMs)
|
||||
// Subscribe to push notifications — no poll timer needed
|
||||
this.unsubCapture = onFrame(session.name, (frame) => this.onNewFrame(frame))
|
||||
}
|
||||
|
||||
detach() {
|
||||
this.stopPolling()
|
||||
if (this.unsubCapture) { this.unsubCapture(); this.unsubCapture = null }
|
||||
if (this.session) {
|
||||
stopCapture(this.session.name)
|
||||
resetHash(this.session.name)
|
||||
}
|
||||
this.session = null
|
||||
this.lastFrame = null
|
||||
this._frameDirty = false
|
||||
}
|
||||
|
||||
flash(durationMs = 2000) {
|
||||
@@ -70,29 +88,19 @@ export class TerminalView extends FrameBufferRenderable {
|
||||
this._idleSince = 0
|
||||
}
|
||||
|
||||
private startPolling() {
|
||||
if (this.pollTimer) return
|
||||
this.pollTimer = setInterval(() => this.refresh(), 80)
|
||||
// Hint that input was sent — request immediate render
|
||||
nudge() {
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
private stopPolling() {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer)
|
||||
this.pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private async refresh() {
|
||||
private onNewFrame(result: CaptureResult) {
|
||||
if (!this.session) return
|
||||
|
||||
const result = await capturePane(this.session.name)
|
||||
if (!result) return
|
||||
|
||||
if (!hasChanged(result.lines)) return
|
||||
if (!hasChanged(result.lines, this.session.name)) return
|
||||
|
||||
const frame = parseAnsiFrame(result.lines, result.width, result.height)
|
||||
this.lastFrame = frame
|
||||
this.renderFrameToBuffer(frame)
|
||||
this._frameDirty = true
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
private renderFrameToBuffer(frame: ParsedFrame) {
|
||||
@@ -113,17 +121,20 @@ export class TerminalView extends FrameBufferRenderable {
|
||||
}
|
||||
}
|
||||
|
||||
// Only write to framebuffer when content actually changed (dirty flag)
|
||||
// Previously this re-rendered EVERY paint cycle — major CPU waste
|
||||
protected renderSelf(buffer: OptimizedBuffer) {
|
||||
if (this.lastFrame) {
|
||||
if (this._frameDirty && this.lastFrame) {
|
||||
this.renderFrameToBuffer(this.lastFrame)
|
||||
this._frameDirty = false
|
||||
}
|
||||
super.renderSelf(buffer)
|
||||
}
|
||||
|
||||
protected onResize(width: number, height: number) {
|
||||
super.onResize(width, height)
|
||||
this._frameDirty = true // Re-render frame to new buffer size
|
||||
if (this.session) {
|
||||
// Resize tmux pane to match (async, fire-and-forget)
|
||||
import("../tmux/session-manager").then(m => {
|
||||
if (this.session) m.resizePane(this.session.name, width, height)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user