2486 lines
61 KiB
Go
2486 lines
61 KiB
Go
//go:build !windows
|
|
|
|
package runtime
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"hash/fnv"
|
|
"io"
|
|
"os"
|
|
"os/signal"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
|
|
"github.com/creack/pty"
|
|
"github.com/maximhq/vt10x"
|
|
"golang.org/x/term"
|
|
)
|
|
|
|
// ErrQuit is returned by RunTabbed when the user quits the chooser
|
|
// without creating any tabs.
|
|
var ErrQuit = errors.New("user quit")
|
|
|
|
// ErrBackToTabs is returned by the newTabFn when the user presses Ctrl+B
|
|
// to dismiss the chooser and return to tab command mode.
|
|
var ErrBackToTabs = errors.New("back to tabs")
|
|
|
|
// ErrUpdateRequested is returned by RunTabbed when the user presses U in
|
|
// tab command mode to trigger a self-update and re-exec.
|
|
var ErrUpdateRequested = errors.New("update requested")
|
|
|
|
const pendingTabLabel = "Bifrost"
|
|
|
|
// Tab represents a single CLI session running in a PTY.
|
|
type Tab struct {
|
|
id int
|
|
label string
|
|
spec LaunchSpec
|
|
ptmx *os.File
|
|
cmd *PreparedCmd
|
|
done chan struct{} // closed when the process exits
|
|
exited atomic.Bool
|
|
exitErr error
|
|
startedAt time.Time
|
|
vt vt10x.Terminal // virtual terminal emulator for this tab's screen state
|
|
normalizer vtStreamNormalizer
|
|
statusMu sync.RWMutex // guards bell, screenHash, lastScreenChange, prevScreenChange
|
|
bell bool // set when BEL received on inactive tab, cleared on switch
|
|
cursorShape atomic.Int32 // DECSCUSR value (0=default, 1-6 per spec)
|
|
cursorVisible atomic.Bool // last cursor visibility from raw PTY (\x1b[?25h/l)
|
|
cursorSavedX atomic.Int32 // cursor X captured when child sends \x1b[?25h
|
|
cursorSavedY atomic.Int32 // cursor Y captured when child sends \x1b[?25h
|
|
cursorSavedValid atomic.Bool // true once we've captured a cursor-show position
|
|
screenHash uint64 // latest visible VT screen fingerprint
|
|
lastScreenChange time.Time
|
|
prevScreenChange time.Time
|
|
lastUserInputAt atomic.Int64
|
|
bellState belParserState
|
|
}
|
|
|
|
type mouseEvent struct {
|
|
x int
|
|
y int
|
|
button int
|
|
press bool
|
|
motion bool
|
|
wheel bool
|
|
}
|
|
|
|
type belParserState struct {
|
|
inOSC bool
|
|
escPending bool
|
|
}
|
|
|
|
// NewTabFunc is called when the user requests a new tab or reopens the
|
|
// chooser for the active tab. It should present any UI needed (e.g. the
|
|
// harness chooser) and return the launch spec. Return a nil spec to cancel.
|
|
// When seed is non-nil, the chooser should use it to prefill the current tab.
|
|
// stdinReader provides keyboard input; when nil the callback should read os.Stdin.
|
|
// tabBarLine returns the current tab bar content for embedding in the chooser view.
|
|
type NewTabFunc func(ctx context.Context, notify func(level TabNoticeLevel, message string), tabBarLine func() string, stdinReader io.Reader, seed *LaunchSpec) (*LaunchSpec, error)
|
|
|
|
// TabManager multiplexes multiple CLI sessions. Each session runs in its own PTY,
|
|
// with a virtual terminal emulator capturing output. A 30fps render loop composites
|
|
// the active tab's screen content with the tab bar into atomic frames.
|
|
type TabManager struct {
|
|
stdout io.Writer
|
|
stderr io.Writer
|
|
version string
|
|
|
|
mu sync.Mutex
|
|
outputMu sync.Mutex
|
|
tabs []*Tab
|
|
activeIdx int
|
|
nextID int
|
|
rows uint16
|
|
cols uint16
|
|
paused bool // true while chooser overlay is active
|
|
commandMode bool // true while the tab-mode overlay owns the terminal
|
|
needsRender bool // set by readPTY/switchTab/resize, cleared by renderFrame
|
|
lastCtrlCAt time.Time
|
|
hostVTMode vt10x.ModeFlag
|
|
noticeText string
|
|
noticeLevel TabNoticeLevel
|
|
noticeUntil time.Time
|
|
noticeSticky bool
|
|
|
|
closeCh chan struct{} // closed when all tabs are gone
|
|
closeOnce sync.Once
|
|
stdinCh chan stdinResult
|
|
stdinPaused atomic.Bool // true while chooser owns os.Stdin
|
|
stdinPollFd *os.File // dup'd non-blocking stdin for the reader goroutine
|
|
|
|
cursorTraceMu sync.Mutex
|
|
cursorTrace io.WriteCloser
|
|
|
|
updateVersion string // non-empty when an update is available
|
|
}
|
|
|
|
// stdinResult carries data from the dedicated stdin-reading goroutine.
|
|
type stdinResult struct {
|
|
data []byte
|
|
err error
|
|
}
|
|
|
|
// RunTabbed enters the tabbed multiplexer. It starts with a tab bar,
|
|
// immediately opens the chooser via newTabFn for the first tab, then enters
|
|
// the main event loop. Returns ErrQuit if the user quits the initial chooser
|
|
// without creating any tabs.
|
|
func RunTabbed(ctx context.Context, stdout, stderr io.Writer, version string, updateVersion string, newTabFn NewTabFunc) error {
|
|
tm := &TabManager{
|
|
stdout: stdout,
|
|
stderr: stderr,
|
|
version: version,
|
|
updateVersion: updateVersion,
|
|
closeCh: make(chan struct{}),
|
|
}
|
|
|
|
// Get terminal size
|
|
if c, r, err := term.GetSize(int(os.Stdout.Fd())); err == nil {
|
|
tm.cols, tm.rows = normalizeTerminalSize(c, r)
|
|
} else {
|
|
tm.cols, tm.rows = normalizeTerminalSize(80, 24)
|
|
}
|
|
|
|
tm.initCursorTrace()
|
|
defer tm.closeCursorTrace()
|
|
|
|
// Enter raw mode so the home screen and tab mode render cleanly.
|
|
oldState, err := term.MakeRaw(int(os.Stdin.Fd()))
|
|
if err != nil {
|
|
// Can't go raw — fall back: open chooser directly, then single-session
|
|
spec, err := newTabFn(ctx, tm.emitNotice, nil, nil, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if spec == nil {
|
|
return ErrQuit
|
|
}
|
|
return RunInteractive(ctx, stdout, stderr, *spec)
|
|
}
|
|
defer term.Restore(int(os.Stdin.Fd()), oldState)
|
|
|
|
defer func() {
|
|
tm.resetHostInputModes()
|
|
tm.writeString("\x1b[2J\x1b[H")
|
|
}()
|
|
|
|
// Draw the tab bar so the user starts in tabbed mode immediately.
|
|
tm.drawTabBar()
|
|
|
|
// Start the frame render loop.
|
|
go tm.renderLoop()
|
|
defer tm.signalClose()
|
|
|
|
// Open the chooser within the tab content area.
|
|
if err := tm.openNewTab(ctx, newTabFn, oldState); err != nil {
|
|
return err
|
|
}
|
|
|
|
// If the user quit the chooser without creating a tab, exit
|
|
if tm.shouldExitWithoutTabs() {
|
|
return ErrQuit
|
|
}
|
|
|
|
// Handle SIGWINCH (terminal resize)
|
|
sigCh := make(chan os.Signal, 1)
|
|
signal.Notify(sigCh, syscall.SIGWINCH)
|
|
defer signal.Stop(sigCh)
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-sigCh:
|
|
tm.handleResize()
|
|
case <-tm.closeCh:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Open a separate non-blocking fd to the controlling terminal for the
|
|
// reader goroutine. We can't use syscall.Dup because dup'd fds share
|
|
// file status flags — SetNonblock would also make os.Stdin non-blocking.
|
|
// Opening /dev/tty creates an independent file description, so its
|
|
// O_NONBLOCK flag is separate from stdin's. This lets Go's runtime
|
|
// poller register the fd, making SetReadDeadline work — which lets
|
|
// openNewTab interrupt a blocked Read so the goroutine yields the
|
|
// terminal to the chooser.
|
|
tm.stdinPollFd, err = os.OpenFile("/dev/tty", os.O_RDONLY|syscall.O_NONBLOCK, 0)
|
|
if err != nil {
|
|
return fmt.Errorf("open /dev/tty: %w", err)
|
|
}
|
|
defer tm.stdinPollFd.Close()
|
|
|
|
tm.stdinCh = make(chan stdinResult, 1)
|
|
go func() {
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
// Yield stdin to the chooser while paused.
|
|
for tm.stdinPaused.Load() {
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
n, err := tm.stdinPollFd.Read(buf)
|
|
// Deadline-induced timeout — just retry.
|
|
if err != nil && os.IsTimeout(err) {
|
|
continue
|
|
}
|
|
// Re-check: may have been paused while blocked on Read.
|
|
if tm.stdinPaused.Load() {
|
|
if err != nil {
|
|
return
|
|
}
|
|
continue // discard — chooser owns stdin
|
|
}
|
|
res := stdinResult{}
|
|
if n > 0 {
|
|
res.data = make([]byte, n)
|
|
copy(res.data, buf[:n])
|
|
}
|
|
if err != nil {
|
|
res.err = err
|
|
}
|
|
tm.stdinCh <- res
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Main input loop
|
|
return tm.inputLoop(ctx, newTabFn, oldState)
|
|
}
|
|
|
|
// addTab creates a new PTY session for the given spec, initializes a virtual
|
|
// terminal emulator, and starts reading PTY output into it.
|
|
func (tm *TabManager) addTab(ctx context.Context, spec LaunchSpec) error {
|
|
tab, err := tm.createTab(ctx, spec)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tm.mu.Lock()
|
|
tm.tabs = append(tm.tabs, tab)
|
|
tm.activeIdx = len(tm.tabs) - 1
|
|
tm.needsRender = true
|
|
tm.mu.Unlock()
|
|
|
|
tm.startTab(tab)
|
|
return nil
|
|
}
|
|
|
|
func (tm *TabManager) createTab(ctx context.Context, spec LaunchSpec) (*Tab, error) {
|
|
p, err := PrepareCommand(ctx, spec)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
contentRows := tm.contentRows()
|
|
cols := tm.cols
|
|
|
|
// Reserve the bottom row for the tab bar.
|
|
ptmx, err := pty.StartWithSize(p.Cmd, &pty.Winsize{
|
|
Rows: contentRows,
|
|
Cols: cols,
|
|
})
|
|
if err != nil {
|
|
if p.Cleanup != nil {
|
|
p.Cleanup()
|
|
}
|
|
return nil, fmt.Errorf("start pty: %w", err)
|
|
}
|
|
|
|
// Build label: "harness" or "harness:worktree"
|
|
label := spec.Harness.Label
|
|
if wt := strings.TrimSpace(spec.Worktree); wt != "" {
|
|
label += ":" + wt
|
|
}
|
|
|
|
tab := &Tab{
|
|
id: tm.nextID,
|
|
label: label,
|
|
spec: spec,
|
|
ptmx: ptmx,
|
|
cmd: p,
|
|
done: make(chan struct{}),
|
|
startedAt: time.Now(),
|
|
vt: vt10x.New(
|
|
vt10x.WithWriter(ptmx),
|
|
vt10x.WithSize(int(cols), int(contentRows)),
|
|
),
|
|
}
|
|
tab.cursorVisible.Store(true)
|
|
tm.nextID++
|
|
|
|
return tab, nil
|
|
}
|
|
|
|
func (tm *TabManager) startTab(tab *Tab) {
|
|
// Read PTY output into the VT emulator
|
|
go tm.readPTY(tab)
|
|
|
|
// Wait for process exit
|
|
go func() {
|
|
tab.exitErr = tab.cmd.Cmd.Wait()
|
|
tab.ptmx.Close()
|
|
tab.exited.Store(true)
|
|
close(tab.done)
|
|
if tab.cmd.Cleanup != nil {
|
|
tab.cmd.Cleanup()
|
|
}
|
|
tm.removeTab(tab)
|
|
}()
|
|
}
|
|
|
|
func (tm *TabManager) addPendingTab() (*Tab, int) {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
|
|
prevActive := tm.activeIdx
|
|
tab := &Tab{
|
|
id: tm.nextID,
|
|
label: pendingTabLabel,
|
|
done: make(chan struct{}),
|
|
}
|
|
tm.nextID++
|
|
tm.tabs = append(tm.tabs, tab)
|
|
tm.activeIdx = len(tm.tabs) - 1
|
|
tm.paused = true
|
|
tm.commandMode = false
|
|
|
|
return tab, prevActive
|
|
}
|
|
|
|
// removePendingTab removes the pending tab and restores the previous active tab.
|
|
func (tm *TabManager) removePendingTab(tab *Tab, restoreActive int) *Tab {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
|
|
idx := -1
|
|
for i, t := range tm.tabs {
|
|
if t == tab {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx >= 0 {
|
|
tm.tabs = append(tm.tabs[:idx], tm.tabs[idx+1:]...)
|
|
}
|
|
|
|
tm.paused = false
|
|
tm.commandMode = false
|
|
|
|
if len(tm.tabs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if restoreActive >= 0 && restoreActive < len(tm.tabs) {
|
|
tm.activeIdx = restoreActive
|
|
} else if tm.activeIdx >= len(tm.tabs) {
|
|
tm.activeIdx = len(tm.tabs) - 1
|
|
}
|
|
|
|
return tm.tabs[tm.activeIdx]
|
|
}
|
|
|
|
// readPTY reads from a tab's PTY master and writes into its VT emulator.
|
|
// When the tab is active, it sets the dirty flag so the render loop
|
|
// composites a new frame.
|
|
func (tm *TabManager) readPTY(tab *Tab) {
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
n, err := tab.ptmx.Read(buf)
|
|
if n > 0 {
|
|
raw := buf[:n]
|
|
tm.traceClaudeCursorBytes(tab, "pty_raw", raw)
|
|
|
|
// Extract cursor shape (DECSCUSR) from raw PTY data before the
|
|
// normalizer/vt10x swallow it.
|
|
if shape := extractCursorShape(raw); shape >= 0 {
|
|
tab.cursorShape.Store(shape)
|
|
}
|
|
if vis := extractCursorVisible(raw); vis >= 0 {
|
|
tab.cursorVisible.Store(vis == 1)
|
|
}
|
|
|
|
// Normalize PTY output before it reaches vt10x so split CSI chunks
|
|
// and colon-style SGR don't poison the emulator state.
|
|
data := tab.normalizer.Normalize(raw)
|
|
tm.traceClaudeCursorBytes(tab, "pty_normalized", data)
|
|
screenChanged := false
|
|
if len(data) > 0 {
|
|
if idx := lastCursorShowIndex(data); idx >= 0 {
|
|
showEnd := idx + len("\x1b[?25h")
|
|
if showEnd > len(data) {
|
|
showEnd = len(data)
|
|
}
|
|
|
|
// Capture the child's intended cursor position at the exact
|
|
// moment it shows the cursor, before any later bytes in the
|
|
// same chunk can move the live VT cursor elsewhere.
|
|
tab.vt.Write(data[:showEnd])
|
|
tab.vt.Lock()
|
|
cursor := tab.vt.Cursor()
|
|
tab.vt.Unlock()
|
|
tab.cursorSavedX.Store(int32(cursor.X))
|
|
tab.cursorSavedY.Store(int32(cursor.Y))
|
|
tab.cursorSavedValid.Store(true)
|
|
tm.traceClaudeCursorf(tab, "cursor_saved_from_show vt_cursor=(%d,%d)", cursor.X, cursor.Y)
|
|
|
|
if showEnd < len(data) {
|
|
tab.vt.Write(data[showEnd:])
|
|
}
|
|
} else {
|
|
// Write to VT emulator — self-locking, parses ANSI sequences.
|
|
tab.vt.Write(data)
|
|
}
|
|
|
|
if tab.cursorVisible.Load() {
|
|
tab.vt.Lock()
|
|
cursor := tab.vt.Cursor()
|
|
vtVisible := tab.vt.CursorVisible()
|
|
tab.vt.Unlock()
|
|
tab.cursorSavedX.Store(int32(cursor.X))
|
|
tab.cursorSavedY.Store(int32(cursor.Y))
|
|
tab.cursorSavedValid.Store(true)
|
|
tm.traceClaudeCursorf(tab, "cursor_saved_visible_chunk vt_cursor=(%d,%d) vt_visible=%t raw_visible=%t",
|
|
cursor.X, cursor.Y, vtVisible, tab.cursorVisible.Load())
|
|
}
|
|
|
|
tab.vt.Lock()
|
|
vtCursor := tab.vt.Cursor()
|
|
vtVisible := tab.vt.CursorVisible()
|
|
tab.vt.Unlock()
|
|
tm.traceClaudeCursorf(tab, "post_write vt_cursor=(%d,%d) vt_visible=%t raw_visible=%t saved_valid=%t saved=(%d,%d)",
|
|
vtCursor.X, vtCursor.Y, vtVisible, tab.cursorVisible.Load(),
|
|
tab.cursorSavedValid.Load(), tab.cursorSavedX.Load(), tab.cursorSavedY.Load())
|
|
|
|
screenChanged = noteTabScreenChange(tab, time.Now())
|
|
}
|
|
|
|
// Only count standalone BEL bytes. OSC/title sequences commonly use
|
|
// BEL as a terminator, and those should not surface as notifications.
|
|
hasBEL := containsStandaloneBEL(raw, &tab.bellState)
|
|
|
|
tm.mu.Lock()
|
|
isActive := !tm.paused &&
|
|
tm.activeIdx < len(tm.tabs) && tm.tabs[tm.activeIdx] == tab
|
|
if isActive && screenChanged {
|
|
tm.needsRender = true
|
|
} else if hasBEL {
|
|
tab.statusMu.Lock()
|
|
tab.bell = true
|
|
tab.statusMu.Unlock()
|
|
tm.needsRender = true // redraw tab bar to show bell
|
|
} else if screenChanged {
|
|
tm.needsRender = true
|
|
}
|
|
tm.mu.Unlock()
|
|
}
|
|
if err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// prefix is the bifrost prefix key: Ctrl+B (0x02).
|
|
// When pressed, PTY output is frozen and bifrost enters tab mode.
|
|
const prefix = 0x02
|
|
|
|
// inputLoop is the main event loop that reads stdin and dispatches to tabs or hotkeys.
|
|
//
|
|
// Global keybindings (always active):
|
|
//
|
|
// Ctrl+1…9 — jump to tab N
|
|
// Ctrl+Tab — cycle to next tab
|
|
// Ctrl+B — toggle tab command mode
|
|
//
|
|
// Keybindings while in tab command mode (^B prefix):
|
|
//
|
|
// N — open new tab (shows chooser)
|
|
// E — edit the current session
|
|
// X — close current tab
|
|
// H/L or J/K — move left/right
|
|
// 1…9 — jump to tab N
|
|
// U — update bifrost (if available)
|
|
// Esc/Enter/Ctrl+B — resume the active tab
|
|
func (tm *TabManager) inputLoop(ctx context.Context, newTabFn NewTabFunc, termState *term.State) error {
|
|
pending := make([]byte, 0, 4096)
|
|
|
|
for {
|
|
if tm.shouldExitWithoutTabs() {
|
|
return nil
|
|
}
|
|
|
|
if len(pending) == 0 {
|
|
if err := tm.waitForInput(ctx, &pending); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
for len(pending) > 0 {
|
|
token, consumed, isPrefix, complete := nextInputToken(pending)
|
|
if !complete {
|
|
if err := tm.waitForInput(ctx, &pending); err != nil {
|
|
return err
|
|
}
|
|
break
|
|
}
|
|
pending = pending[consumed:]
|
|
|
|
if isTerminalResponse(token) {
|
|
continue
|
|
}
|
|
|
|
if isPrefix {
|
|
if tm.isCommandMode() {
|
|
if err := tm.handleCommandKey(ctx, newTabFn, termState, prefix); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
tm.enterCommandMode()
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Ctrl+1..9 — jump to tab (works in any mode)
|
|
if idx := parseCtrlDigit(token); idx >= 0 {
|
|
if tm.isCommandMode() {
|
|
tm.exitCommandMode()
|
|
}
|
|
tm.switchTab(idx)
|
|
continue
|
|
}
|
|
|
|
// Ctrl+Tab — cycle to next tab (works in any mode)
|
|
if isCtrlTab(token) {
|
|
if tm.isCommandMode() {
|
|
tm.exitCommandMode()
|
|
}
|
|
tm.cycleTab()
|
|
continue
|
|
}
|
|
|
|
if ev, ok := parseMouseEvent(token); ok {
|
|
if tm.handleTabBarMouseEvent(ev) {
|
|
continue
|
|
}
|
|
if tm.isCommandMode() {
|
|
continue
|
|
}
|
|
}
|
|
|
|
if tm.isCommandMode() {
|
|
if b, ok := decodeCommandByte(token); ok {
|
|
if err := tm.handleCommandKey(ctx, newTabFn, termState, b); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Decode kitty keyboard protocol CSI u sequences into standard
|
|
// terminal input before forwarding. This handles key release
|
|
// events (dropped) and regular keys that were encoded as CSI u
|
|
// because a child process enabled the kitty protocol.
|
|
if isCSIu(token) {
|
|
if decoded := decodeCSIu(token); decoded != nil {
|
|
if tm.handleActiveCtrlC(decoded) {
|
|
continue
|
|
}
|
|
tm.forwardToActive(decoded)
|
|
}
|
|
continue
|
|
}
|
|
|
|
if tm.handleActiveCtrlC(token) {
|
|
continue
|
|
}
|
|
tm.forwardToActive(token)
|
|
}
|
|
}
|
|
}
|
|
|
|
// waitForInput blocks until stdin data arrives, the context is cancelled,
|
|
// or all tabs have closed — whichever comes first.
|
|
func (tm *TabManager) waitForInput(ctx context.Context, pending *[]byte) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-tm.closeCh:
|
|
return nil
|
|
case res := <-tm.stdinCh:
|
|
if len(res.data) > 0 {
|
|
*pending = append(*pending, res.data...)
|
|
}
|
|
return res.err
|
|
}
|
|
}
|
|
|
|
func nextInputToken(buf []byte) ([]byte, int, bool, bool) {
|
|
if len(buf) == 0 {
|
|
return nil, 0, false, false
|
|
}
|
|
|
|
if buf[0] == prefix {
|
|
return buf[:1], 1, true, true
|
|
}
|
|
|
|
if buf[0] != 0x1b {
|
|
return buf[:1], 1, false, true
|
|
}
|
|
|
|
if len(buf) == 1 {
|
|
return buf[:1], 1, false, true
|
|
}
|
|
|
|
if buf[1] == ']' || buf[1] == 'P' || buf[1] == '^' || buf[1] == '_' {
|
|
for i := 2; i < len(buf); i++ {
|
|
if buf[i] == 0x07 {
|
|
return buf[:i+1], i + 1, false, true
|
|
}
|
|
if buf[i] == 0x1b && i+1 < len(buf) && buf[i+1] == '\\' {
|
|
return buf[:i+2], i + 2, false, true
|
|
}
|
|
}
|
|
return nil, 0, false, false
|
|
}
|
|
|
|
if buf[1] != '[' && buf[1] != 'O' {
|
|
return buf[:1], 1, false, true
|
|
}
|
|
|
|
// X10 mouse tracking reports use CSI M followed by three data bytes.
|
|
if buf[1] == '[' && len(buf) >= 3 && buf[2] == 'M' {
|
|
if len(buf) < 6 {
|
|
return nil, 0, false, false
|
|
}
|
|
return buf[:6], 6, false, true
|
|
}
|
|
|
|
for i := 2; i < len(buf); i++ {
|
|
if buf[i] >= 0x40 && buf[i] <= 0x7e {
|
|
token := buf[:i+1]
|
|
return token, i + 1, isCtrlBSequence(token), true
|
|
}
|
|
}
|
|
|
|
return nil, 0, false, false
|
|
}
|
|
|
|
func isTerminalResponse(seq []byte) bool {
|
|
if len(seq) < 2 || seq[0] != 0x1b {
|
|
return false
|
|
}
|
|
|
|
switch seq[1] {
|
|
case ']', 'P', '^', '_':
|
|
return true
|
|
case '[':
|
|
final := seq[len(seq)-1]
|
|
switch final {
|
|
case 'R', 'c', 'n', 't':
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func containsStandaloneBEL(data []byte, state *belParserState) bool {
|
|
if state == nil {
|
|
return false
|
|
}
|
|
|
|
found := false
|
|
for _, b := range data {
|
|
if state.inOSC {
|
|
if state.escPending {
|
|
state.escPending = false
|
|
if b == '\\' {
|
|
state.inOSC = false
|
|
continue
|
|
}
|
|
if b == 0x1b {
|
|
state.escPending = true
|
|
}
|
|
continue
|
|
}
|
|
|
|
switch b {
|
|
case 0x07:
|
|
state.inOSC = false
|
|
case 0x1b:
|
|
state.escPending = true
|
|
}
|
|
continue
|
|
}
|
|
|
|
if state.escPending {
|
|
state.escPending = false
|
|
if b == ']' {
|
|
state.inOSC = true
|
|
continue
|
|
}
|
|
if b == 0x1b {
|
|
state.escPending = true
|
|
continue
|
|
}
|
|
}
|
|
|
|
if b == 0x1b {
|
|
state.escPending = true
|
|
continue
|
|
}
|
|
if b == 0x07 {
|
|
found = true
|
|
}
|
|
}
|
|
|
|
return found
|
|
}
|
|
|
|
func isCtrlBSequence(seq []byte) bool {
|
|
if len(seq) < 4 || seq[0] != 0x1b || seq[1] != '[' {
|
|
return false
|
|
}
|
|
|
|
final := seq[len(seq)-1]
|
|
body := string(seq[2 : len(seq)-1])
|
|
|
|
switch final {
|
|
case 'u':
|
|
parts := strings.Split(body, ";")
|
|
if len(parts) < 2 {
|
|
return false
|
|
}
|
|
if isReleaseEvent(parts[1]) {
|
|
return false
|
|
}
|
|
return isCtrlBCode(parts[0]) && modifierHasCtrl(parts[1])
|
|
case '~':
|
|
parts := strings.Split(body, ";")
|
|
if len(parts) < 3 || parts[0] != "27" {
|
|
return false
|
|
}
|
|
if isReleaseEvent(parts[1]) {
|
|
return false
|
|
}
|
|
return isCtrlBCode(parts[2]) && modifierHasCtrl(parts[1])
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// isReleaseEvent checks if a kitty keyboard protocol modifier field indicates
|
|
// a key release event (event_type 3, encoded as ":3" suffix).
|
|
func isReleaseEvent(modField string) bool {
|
|
if idx := strings.IndexByte(modField, ':'); idx >= 0 {
|
|
return modField[idx+1:] == "3"
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isCtrlBCode(s string) bool {
|
|
return s == "98" || s == "66"
|
|
}
|
|
|
|
// parseCtrlDigit checks if a CSI sequence is Ctrl+1..9 and returns the
|
|
// 0-based tab index (0..8). Returns -1 if not a Ctrl+digit sequence.
|
|
func parseCtrlDigit(seq []byte) int {
|
|
if len(seq) < 4 || seq[0] != 0x1b || seq[1] != '[' {
|
|
return -1
|
|
}
|
|
final := seq[len(seq)-1]
|
|
body := string(seq[2 : len(seq)-1])
|
|
|
|
switch final {
|
|
case 'u': // CSI u: \x1b[codepoint;modifiers u
|
|
parts := strings.Split(body, ";")
|
|
if len(parts) < 2 || !modifierHasCtrl(parts[1]) || isReleaseEvent(parts[1]) {
|
|
return -1
|
|
}
|
|
cp, err := strconv.Atoi(parts[0])
|
|
if err != nil {
|
|
return -1
|
|
}
|
|
if cp >= '1' && cp <= '9' {
|
|
return cp - '1'
|
|
}
|
|
case '~': // xterm modkeys: \x1b[27;modifier;codepoint ~
|
|
parts := strings.Split(body, ";")
|
|
if len(parts) < 3 || parts[0] != "27" || !modifierHasCtrl(parts[1]) || isReleaseEvent(parts[1]) {
|
|
return -1
|
|
}
|
|
cp, err := strconv.Atoi(parts[2])
|
|
if err != nil {
|
|
return -1
|
|
}
|
|
if cp >= '1' && cp <= '9' {
|
|
return cp - '1'
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
// isCtrlTab checks if a CSI sequence is Ctrl+Tab.
|
|
func isCtrlTab(seq []byte) bool {
|
|
if len(seq) < 4 || seq[0] != 0x1b || seq[1] != '[' {
|
|
return false
|
|
}
|
|
final := seq[len(seq)-1]
|
|
body := string(seq[2 : len(seq)-1])
|
|
|
|
switch final {
|
|
case 'u': // CSI u: \x1b[9;5u
|
|
parts := strings.Split(body, ";")
|
|
if len(parts) < 2 || isReleaseEvent(parts[1]) {
|
|
return false
|
|
}
|
|
return parts[0] == "9" && modifierHasCtrl(parts[1])
|
|
case '~': // xterm: \x1b[27;5;9~
|
|
parts := strings.Split(body, ";")
|
|
if len(parts) < 3 || parts[0] != "27" || isReleaseEvent(parts[1]) {
|
|
return false
|
|
}
|
|
return parts[2] == "9" && modifierHasCtrl(parts[1])
|
|
}
|
|
return false
|
|
}
|
|
|
|
func modifierHasCtrl(s string) bool {
|
|
mod := parseModifierValue(s)
|
|
if mod <= 0 {
|
|
return false
|
|
}
|
|
return ((mod - 1) & 4) != 0
|
|
}
|
|
|
|
func parseModifierValue(s string) int {
|
|
if idx := strings.IndexByte(s, ':'); idx >= 0 {
|
|
s = s[:idx]
|
|
}
|
|
|
|
mod, err := strconv.Atoi(s)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
|
|
return mod
|
|
}
|
|
|
|
func decodeCommandByte(token []byte) (byte, bool) {
|
|
if len(token) == 1 {
|
|
return token[0], true
|
|
}
|
|
|
|
if len(token) < 4 || token[0] != 0x1b || token[1] != '[' {
|
|
return 0, false
|
|
}
|
|
|
|
final := token[len(token)-1]
|
|
body := string(token[2 : len(token)-1])
|
|
|
|
switch final {
|
|
case 'u':
|
|
parts := strings.Split(body, ";")
|
|
if len(parts) < 1 || (len(parts) >= 2 && isReleaseEvent(parts[1])) {
|
|
return 0, false
|
|
}
|
|
cp, err := strconv.Atoi(parts[0])
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return decodeASCIICommandCodepoint(cp)
|
|
case '~':
|
|
parts := strings.Split(body, ";")
|
|
if len(parts) < 3 || parts[0] != "27" || isReleaseEvent(parts[1]) {
|
|
return 0, false
|
|
}
|
|
cp, err := strconv.Atoi(parts[2])
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return decodeASCIICommandCodepoint(cp)
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
// isCSIu checks if a token is a kitty keyboard protocol CSI u sequence.
|
|
func isCSIu(seq []byte) bool {
|
|
return len(seq) >= 4 && seq[0] == 0x1b && seq[1] == '[' && seq[len(seq)-1] == 'u'
|
|
}
|
|
|
|
// decodeCSIu translates a kitty keyboard protocol CSI u sequence into standard
|
|
// terminal input bytes suitable for forwarding to a PTY. Returns nil for key
|
|
// release events (event_type 3) which should be silently dropped.
|
|
func decodeCSIu(seq []byte) []byte {
|
|
body := string(seq[2 : len(seq)-1])
|
|
parts := strings.Split(body, ";")
|
|
if len(parts) < 1 {
|
|
return nil
|
|
}
|
|
|
|
cpStr := parts[0]
|
|
// Strip sub-parameters (e.g. shifted-key, base-layout-key)
|
|
if idx := strings.IndexByte(cpStr, ':'); idx >= 0 {
|
|
cpStr = cpStr[:idx]
|
|
}
|
|
cp, err := strconv.Atoi(cpStr)
|
|
if err != nil || cp <= 0 || cp > 0x10FFFF {
|
|
return nil
|
|
}
|
|
|
|
mod := 1
|
|
if len(parts) >= 2 {
|
|
modField := parts[1]
|
|
// Check for release event type (:3)
|
|
if idx := strings.IndexByte(modField, ':'); idx >= 0 {
|
|
if evtStr := modField[idx+1:]; evtStr == "3" {
|
|
return nil // drop release events
|
|
}
|
|
}
|
|
if m := parseModifierValue(modField); m > 0 {
|
|
mod = m
|
|
}
|
|
}
|
|
|
|
hasCtrl := ((mod - 1) & 4) != 0
|
|
hasAlt := ((mod - 1) & 2) != 0
|
|
|
|
var ch []byte
|
|
switch {
|
|
case hasCtrl && cp >= 'a' && cp <= 'z':
|
|
ch = []byte{byte(cp - 'a' + 1)}
|
|
case hasCtrl && cp >= 'A' && cp <= 'Z':
|
|
ch = []byte{byte(cp - 'A' + 1)}
|
|
case hasCtrl && cp == ' ':
|
|
ch = []byte{0}
|
|
case cp == 0x1b:
|
|
ch = []byte{0x1b}
|
|
case cp == '\r' || cp == '\n' || cp == '\t' || cp == 0x7f:
|
|
ch = []byte{byte(cp)}
|
|
case cp < 128:
|
|
ch = []byte{byte(cp)}
|
|
default:
|
|
// Unicode codepoint — encode as UTF-8
|
|
ch = []byte(string(rune(cp)))
|
|
}
|
|
|
|
if hasAlt {
|
|
ch = append([]byte{0x1b}, ch...)
|
|
}
|
|
|
|
return ch
|
|
}
|
|
|
|
func decodeASCIICommandCodepoint(cp int) (byte, bool) {
|
|
switch {
|
|
case cp == 0x1b || cp == '\r' || cp == '\n' || cp == '\t':
|
|
return byte(cp), true
|
|
case cp >= 0x20 && cp <= 0x7e:
|
|
return byte(cp), true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
func parseMouseEvent(seq []byte) (mouseEvent, bool) {
|
|
if len(seq) < 6 || seq[0] != 0x1b || seq[1] != '[' {
|
|
return mouseEvent{}, false
|
|
}
|
|
|
|
if seq[2] == 'M' {
|
|
cb := int(seq[3]) - 32
|
|
x := int(seq[4]) - 32
|
|
y := int(seq[5]) - 32
|
|
if cb < 0 || x < 1 || y < 1 {
|
|
return mouseEvent{}, false
|
|
}
|
|
return mouseEvent{
|
|
x: x,
|
|
y: y,
|
|
button: cb & 0x03,
|
|
press: true,
|
|
motion: cb&0x20 != 0,
|
|
wheel: cb&0x40 != 0,
|
|
}, true
|
|
}
|
|
|
|
if seq[2] != '<' {
|
|
return mouseEvent{}, false
|
|
}
|
|
|
|
final := seq[len(seq)-1]
|
|
if final != 'M' && final != 'm' {
|
|
return mouseEvent{}, false
|
|
}
|
|
|
|
body := string(seq[3 : len(seq)-1])
|
|
parts := strings.Split(body, ";")
|
|
if len(parts) != 3 {
|
|
return mouseEvent{}, false
|
|
}
|
|
|
|
cb, err := strconv.Atoi(parts[0])
|
|
if err != nil {
|
|
return mouseEvent{}, false
|
|
}
|
|
x, err := strconv.Atoi(parts[1])
|
|
if err != nil {
|
|
return mouseEvent{}, false
|
|
}
|
|
y, err := strconv.Atoi(parts[2])
|
|
if err != nil || x < 1 || y < 1 {
|
|
return mouseEvent{}, false
|
|
}
|
|
|
|
return mouseEvent{
|
|
x: x,
|
|
y: y,
|
|
button: cb & 0x03,
|
|
press: final == 'M',
|
|
motion: cb&0x20 != 0,
|
|
wheel: cb&0x40 != 0,
|
|
}, true
|
|
}
|
|
|
|
func (tm *TabManager) isCommandMode() bool {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
return tm.commandMode
|
|
}
|
|
|
|
func (tm *TabManager) shouldExitWithoutTabs() bool {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
return len(tm.tabs) == 0 && !tm.commandMode
|
|
}
|
|
|
|
func (tm *TabManager) emitNotice(level TabNoticeLevel, message string) {
|
|
message = strings.TrimSpace(message)
|
|
if message == "" {
|
|
return
|
|
}
|
|
|
|
tm.mu.Lock()
|
|
tm.noticeText = message
|
|
tm.noticeLevel = level
|
|
tm.noticeSticky = level == TabNoticeError
|
|
if level == TabNoticeError {
|
|
tm.noticeUntil = time.Now().Add(8 * time.Second)
|
|
tm.commandMode = true
|
|
} else {
|
|
tm.noticeUntil = time.Now().Add(3 * time.Second)
|
|
}
|
|
tm.needsRender = true
|
|
hasTabs := len(tm.tabs) > 0
|
|
tm.mu.Unlock()
|
|
|
|
if !hasTabs {
|
|
tm.drawTabBar()
|
|
}
|
|
}
|
|
|
|
func (tm *TabManager) clearNotice() bool {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
if tm.noticeText == "" {
|
|
return false
|
|
}
|
|
tm.noticeText = ""
|
|
tm.noticeLevel = TabNoticeInfo
|
|
tm.noticeUntil = time.Time{}
|
|
tm.noticeSticky = false
|
|
tm.needsRender = true
|
|
return true
|
|
}
|
|
|
|
func (tm *TabManager) hasStickyErrorNotice() bool {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
return tm.noticeText != "" && tm.noticeSticky && tm.noticeLevel == TabNoticeError
|
|
}
|
|
|
|
func (tm *TabManager) clearNoticeAndStayInCommandMode() {
|
|
hadNotice := tm.clearNotice()
|
|
if tm.hasTabs() {
|
|
tm.enterCommandMode()
|
|
return
|
|
}
|
|
if hadNotice {
|
|
tm.drawTabBar()
|
|
}
|
|
}
|
|
|
|
func (tm *TabManager) clearNoticeAndResume() {
|
|
hadNotice := tm.clearNotice()
|
|
if tm.hasTabs() {
|
|
tm.exitCommandMode()
|
|
return
|
|
}
|
|
if hadNotice {
|
|
tm.drawTabBar()
|
|
}
|
|
}
|
|
|
|
func (tm *TabManager) hasTabs() bool {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
return len(tm.tabs) > 0
|
|
}
|
|
|
|
func (tm *TabManager) enterCommandMode() {
|
|
tm.mu.Lock()
|
|
tm.commandMode = true
|
|
tm.needsRender = true
|
|
hasTabs := len(tm.tabs) > 0
|
|
tm.mu.Unlock()
|
|
|
|
if !hasTabs {
|
|
tm.writeString("\x1b[2J\x1b[H")
|
|
tm.drawTabBar()
|
|
}
|
|
}
|
|
|
|
func (tm *TabManager) exitCommandMode() {
|
|
tm.mu.Lock()
|
|
tm.commandMode = false
|
|
tm.needsRender = true
|
|
tm.mu.Unlock()
|
|
}
|
|
|
|
func (tm *TabManager) handleCommandKey(ctx context.Context, newTabFn NewTabFunc, termState *term.State, b byte) error {
|
|
switch {
|
|
case b == ' ':
|
|
if tm.hasStickyErrorNotice() {
|
|
tm.clearNoticeAndStayInCommandMode()
|
|
return nil
|
|
}
|
|
tm.clearNoticeAndResume()
|
|
case b == prefix || b == 0x1b || b == '\r' || b == '\n':
|
|
if tm.hasStickyErrorNotice() {
|
|
if b == 0x1b {
|
|
tm.clearNoticeAndStayInCommandMode()
|
|
}
|
|
return nil
|
|
}
|
|
if !tm.hasTabs() {
|
|
if b == 0x1b {
|
|
return tm.openNewTab(ctx, newTabFn, termState)
|
|
}
|
|
tm.drawTabBar()
|
|
return nil
|
|
}
|
|
tm.exitCommandMode()
|
|
case b >= '1' && b <= '9':
|
|
tm.switchTabAndResume(int(b - '1'))
|
|
case b == 'n' || b == 'N':
|
|
return tm.openNewTab(ctx, newTabFn, termState)
|
|
case isEditSessionKey(b):
|
|
return tm.openCurrentTabChooser(ctx, newTabFn, termState)
|
|
case b == 'x' || b == 'X' || b == 'w' || b == 'W':
|
|
tm.closeCurrentTab()
|
|
case b == 'l' || b == 'L' || b == 'j' || b == 'J' || b == '\t':
|
|
tm.moveTabSelection(1)
|
|
case b == 'h' || b == 'H' || b == 'k' || b == 'K' || b == 'p' || b == 'P':
|
|
tm.moveTabSelection(-1)
|
|
case b == 'u' || b == 'U':
|
|
if tm.updateVersion != "" {
|
|
return ErrUpdateRequested
|
|
}
|
|
tm.emitNotice(TabNoticeInfo, "already up to date")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isEditSessionKey(b byte) bool {
|
|
return b == 'e' || b == 'E'
|
|
}
|
|
|
|
// switchTabAndResume exits command mode and activates the selected tab.
|
|
func (tm *TabManager) switchTabAndResume(idx int) {
|
|
tm.mu.Lock()
|
|
if idx < 0 || idx >= len(tm.tabs) {
|
|
tm.mu.Unlock()
|
|
return
|
|
}
|
|
tm.commandMode = false
|
|
tm.activeIdx = idx
|
|
tab := tm.tabs[idx]
|
|
tab.statusMu.Lock()
|
|
tab.bell = false
|
|
tab.statusMu.Unlock()
|
|
tm.needsRender = true
|
|
tm.mu.Unlock()
|
|
}
|
|
|
|
// forwardToActive writes bytes to the active tab's PTY.
|
|
func (tm *TabManager) forwardToActive(data []byte) {
|
|
tm.mu.Lock()
|
|
var ptmx *os.File
|
|
var tab *Tab
|
|
if tm.activeIdx >= 0 && tm.activeIdx < len(tm.tabs) {
|
|
tab = tm.tabs[tm.activeIdx]
|
|
ptmx = tab.ptmx
|
|
if isLikelyTypingInput(data) {
|
|
noteTabUserInput(tab, time.Now())
|
|
}
|
|
}
|
|
tm.mu.Unlock()
|
|
tm.traceClaudeCursorBytes(tab, "stdin_forward", data)
|
|
if ptmx != nil {
|
|
_, _ = ptmx.Write(data)
|
|
}
|
|
}
|
|
|
|
func isLikelyTypingInput(data []byte) bool {
|
|
if len(data) == 0 {
|
|
return false
|
|
}
|
|
|
|
if data[0] == 0x1b {
|
|
return len(data) > 1 && isLikelyTypingInput(data[1:])
|
|
}
|
|
|
|
if len(data) == 1 {
|
|
switch data[0] {
|
|
case '\r', '\n', '\t', 0x7f:
|
|
return true
|
|
}
|
|
return data[0] >= 0x20 && data[0] != 0x7f
|
|
}
|
|
|
|
r, _ := utf8.DecodeRune(data)
|
|
return r != utf8.RuneError && !unicode.IsControl(r) && unicode.IsPrint(r)
|
|
}
|
|
|
|
func (tm *TabManager) handleActiveCtrlC(data []byte) bool {
|
|
if len(data) == 1 && data[0] == 0x03 {
|
|
// If the active tab's process has exited, one Ctrl+C is enough.
|
|
tm.mu.Lock()
|
|
var tab *Tab
|
|
if tm.activeIdx >= 0 && tm.activeIdx < len(tm.tabs) {
|
|
tab = tm.tabs[tm.activeIdx]
|
|
}
|
|
tm.mu.Unlock()
|
|
if tab != nil && tab.exited.Load() {
|
|
tm.resetCtrlC()
|
|
tm.closeCurrentTab()
|
|
return true
|
|
}
|
|
|
|
if tm.noteCtrlC(time.Now()) {
|
|
tm.resetCtrlC()
|
|
tm.closeCurrentTab()
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
tm.resetCtrlC()
|
|
return false
|
|
}
|
|
|
|
func (tm *TabManager) noteCtrlC(now time.Time) bool {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
|
|
forceClose := !tm.lastCtrlCAt.IsZero() && now.Sub(tm.lastCtrlCAt) <= tabCtrlCExitWindow
|
|
tm.lastCtrlCAt = now
|
|
return forceClose
|
|
}
|
|
|
|
func (tm *TabManager) resetCtrlC() {
|
|
tm.mu.Lock()
|
|
tm.lastCtrlCAt = time.Time{}
|
|
tm.mu.Unlock()
|
|
}
|
|
|
|
// switchTab activates the tab at the given index.
|
|
func (tm *TabManager) switchTab(idx int) {
|
|
tm.mu.Lock()
|
|
if idx < 0 || idx >= len(tm.tabs) || idx == tm.activeIdx {
|
|
tm.mu.Unlock()
|
|
return
|
|
}
|
|
tm.activeIdx = idx
|
|
clickedTab := tm.tabs[idx]
|
|
clickedTab.statusMu.Lock()
|
|
clickedTab.bell = false
|
|
clickedTab.statusMu.Unlock()
|
|
tm.needsRender = true
|
|
tm.mu.Unlock()
|
|
}
|
|
|
|
func (tm *TabManager) handleTabBarMouseEvent(ev mouseEvent) bool {
|
|
if !ev.press || ev.motion || ev.wheel || ev.button != 0 {
|
|
return false
|
|
}
|
|
|
|
tm.mu.Lock()
|
|
row := int(tm.rows)
|
|
commandMode := tm.commandMode
|
|
tabs := make([]*Tab, len(tm.tabs))
|
|
copy(tabs, tm.tabs)
|
|
tm.mu.Unlock()
|
|
|
|
if ev.y != row {
|
|
return false
|
|
}
|
|
|
|
idx := tabBarTabIndexAtColumn(tabs, ev.x)
|
|
if idx < 0 {
|
|
return false
|
|
}
|
|
|
|
if commandMode {
|
|
tm.switchTabAndResume(idx)
|
|
} else {
|
|
tm.switchTab(idx)
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// cycleTab moves to the next tab, wrapping around.
|
|
func (tm *TabManager) cycleTab() {
|
|
tm.mu.Lock()
|
|
if len(tm.tabs) <= 1 {
|
|
tm.mu.Unlock()
|
|
return
|
|
}
|
|
next := (tm.activeIdx + 1) % len(tm.tabs)
|
|
tm.activeIdx = next
|
|
tm.needsRender = true
|
|
tm.mu.Unlock()
|
|
}
|
|
|
|
func (tm *TabManager) moveTabSelection(delta int) {
|
|
tm.mu.Lock()
|
|
if len(tm.tabs) == 0 {
|
|
tm.mu.Unlock()
|
|
return
|
|
}
|
|
next := (tm.activeIdx + delta + len(tm.tabs)) % len(tm.tabs)
|
|
tm.activeIdx = next
|
|
tm.needsRender = true
|
|
tm.mu.Unlock()
|
|
}
|
|
|
|
// closeAllTabs sends SIGHUP to every tab's process for a clean exit.
|
|
func (tm *TabManager) closeAllTabs() {
|
|
tm.mu.Lock()
|
|
tabs := make([]*Tab, len(tm.tabs))
|
|
copy(tabs, tm.tabs)
|
|
tm.mu.Unlock()
|
|
|
|
for _, tab := range tabs {
|
|
if tab.cmd != nil && tab.cmd.Cmd.Process != nil && !tab.exited.Load() {
|
|
tab.cmd.Cmd.Process.Signal(syscall.SIGHUP)
|
|
}
|
|
}
|
|
|
|
// Wait briefly for processes to exit gracefully
|
|
timeout := time.After(500 * time.Millisecond)
|
|
for _, tab := range tabs {
|
|
select {
|
|
case <-tab.done:
|
|
case <-timeout:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// closeCurrentTab sends SIGHUP to the active tab's process.
|
|
func (tm *TabManager) closeCurrentTab() {
|
|
tm.mu.Lock()
|
|
if tm.activeIdx >= len(tm.tabs) {
|
|
tm.mu.Unlock()
|
|
return
|
|
}
|
|
tab := tm.tabs[tm.activeIdx]
|
|
tm.mu.Unlock()
|
|
|
|
if tab.cmd != nil && tab.cmd.Cmd.Process != nil && !tab.exited.Load() {
|
|
tab.cmd.Cmd.Process.Signal(syscall.SIGHUP)
|
|
}
|
|
}
|
|
|
|
func (tm *TabManager) signalClose() {
|
|
tm.closeOnce.Do(func() { close(tm.closeCh) })
|
|
}
|
|
|
|
// removeTab removes a dead tab and switches to an adjacent one.
|
|
func (tm *TabManager) removeTab(tab *Tab) {
|
|
tm.mu.Lock()
|
|
tm.lastCtrlCAt = time.Time{}
|
|
idx := -1
|
|
for i, t := range tm.tabs {
|
|
if t == tab {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
tm.mu.Unlock()
|
|
return
|
|
}
|
|
tm.tabs = append(tm.tabs[:idx], tm.tabs[idx+1:]...)
|
|
|
|
if len(tm.tabs) == 0 {
|
|
tm.mu.Unlock()
|
|
tm.signalClose()
|
|
return
|
|
}
|
|
|
|
// Adjust active index
|
|
if tm.activeIdx >= len(tm.tabs) {
|
|
tm.activeIdx = len(tm.tabs) - 1
|
|
}
|
|
tm.needsRender = true
|
|
tm.mu.Unlock()
|
|
}
|
|
|
|
// openNewTab pauses PTY rendering, restores the terminal, runs the chooser,
|
|
// then resumes the multiplexer with the new tab.
|
|
func (tm *TabManager) openNewTab(ctx context.Context, newTabFn NewTabFunc, termState *term.State) error {
|
|
if newTabFn == nil {
|
|
return nil
|
|
}
|
|
|
|
pendingTab, prevActive := tm.addPendingTab()
|
|
spec, err := tm.runChooser(ctx, newTabFn, termState, nil)
|
|
|
|
if err != nil || spec == nil {
|
|
// Cancelled — remove the placeholder tab and resume the previous session.
|
|
activeTab := tm.removePendingTab(pendingTab, prevActive)
|
|
|
|
if errors.Is(err, ErrUpdateRequested) {
|
|
return err
|
|
}
|
|
|
|
// Ctrl+B → enter command mode so the user lands on the tab bar.
|
|
if errors.Is(err, ErrBackToTabs) {
|
|
tm.enterCommandMode()
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
tm.emitNotice(TabNoticeError, err.Error())
|
|
}
|
|
|
|
if activeTab != nil {
|
|
tm.mu.Lock()
|
|
tm.needsRender = true
|
|
tm.mu.Unlock()
|
|
} else {
|
|
tm.drawTabBar()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
activeTab := tm.removePendingTab(pendingTab, prevActive)
|
|
_ = activeTab
|
|
|
|
// Add the new tab
|
|
if err := tm.addTab(ctx, *spec); err != nil {
|
|
tm.emitNotice(TabNoticeError, fmt.Sprintf("new tab failed: %v", err))
|
|
if activeTab != nil {
|
|
tm.mu.Lock()
|
|
tm.needsRender = true
|
|
tm.mu.Unlock()
|
|
} else {
|
|
tm.drawTabBar()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Resume — always exit command mode so the new tab renders.
|
|
tm.mu.Lock()
|
|
tm.paused = false
|
|
tm.commandMode = false
|
|
tm.needsRender = true
|
|
tm.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (tm *TabManager) openCurrentTabChooser(ctx context.Context, newTabFn NewTabFunc, termState *term.State) error {
|
|
if newTabFn == nil {
|
|
return nil
|
|
}
|
|
|
|
currentTab, current, ok := tm.activeTabSeed()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
tm.mu.Lock()
|
|
tm.paused = true
|
|
tm.commandMode = false
|
|
tm.needsRender = true
|
|
tm.mu.Unlock()
|
|
|
|
spec, err := tm.runChooser(ctx, newTabFn, termState, ¤t)
|
|
if err != nil || spec == nil {
|
|
if errors.Is(err, ErrUpdateRequested) {
|
|
return err
|
|
}
|
|
tm.mu.Lock()
|
|
tm.paused = false
|
|
tm.needsRender = true
|
|
tm.mu.Unlock()
|
|
|
|
if errors.Is(err, ErrBackToTabs) {
|
|
tm.enterCommandMode()
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
tm.emitNotice(TabNoticeError, err.Error())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if err := tm.replaceTab(ctx, currentTab, *spec); err != nil {
|
|
tm.mu.Lock()
|
|
tm.paused = false
|
|
tm.needsRender = true
|
|
tm.mu.Unlock()
|
|
tm.emitNotice(TabNoticeError, fmt.Sprintf("tab relaunch failed: %v", err))
|
|
return nil
|
|
}
|
|
|
|
tm.mu.Lock()
|
|
tm.paused = false
|
|
tm.commandMode = false
|
|
tm.needsRender = true
|
|
tm.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
func (tm *TabManager) activeTabSeed() (*Tab, LaunchSpec, bool) {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
|
|
if tm.activeIdx < 0 || tm.activeIdx >= len(tm.tabs) {
|
|
return nil, LaunchSpec{}, false
|
|
}
|
|
tab := tm.tabs[tm.activeIdx]
|
|
return tab, tab.spec, true
|
|
}
|
|
|
|
func (tm *TabManager) replaceTab(ctx context.Context, target *Tab, spec LaunchSpec) error {
|
|
newTab, err := tm.createTab(ctx, spec)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var oldTab *Tab
|
|
tm.mu.Lock()
|
|
replaceIdx := -1
|
|
for i, tab := range tm.tabs {
|
|
if tab == target {
|
|
replaceIdx = i
|
|
break
|
|
}
|
|
}
|
|
if replaceIdx >= 0 {
|
|
oldTab = tm.tabs[replaceIdx]
|
|
tm.tabs[replaceIdx] = newTab
|
|
tm.activeIdx = replaceIdx
|
|
} else {
|
|
tm.tabs = append(tm.tabs, newTab)
|
|
tm.activeIdx = len(tm.tabs) - 1
|
|
}
|
|
tm.needsRender = true
|
|
tm.mu.Unlock()
|
|
|
|
tm.startTab(newTab)
|
|
|
|
if oldTab != nil && oldTab.cmd != nil && oldTab.cmd.Cmd.Process != nil && !oldTab.exited.Load() {
|
|
_ = oldTab.cmd.Cmd.Process.Signal(syscall.SIGHUP)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (tm *TabManager) runChooser(ctx context.Context, newTabFn NewTabFunc, termState *term.State, seed *LaunchSpec) (*LaunchSpec, error) {
|
|
fullscreenChooser := prefersFullscreenChooser()
|
|
|
|
// Clear screen for the chooser. On most terminals, show the tab bar
|
|
// above the chooser; Apple Terminal gets a full-screen render.
|
|
tm.resetHostInputModes()
|
|
tm.writeString("\x1b[2J\x1b[H")
|
|
if !fullscreenChooser {
|
|
tm.drawTabBar()
|
|
}
|
|
if termState != nil {
|
|
_ = term.Restore(int(os.Stdin.Fd()), termState)
|
|
}
|
|
|
|
// Pause the stdinCh goroutine so Bubble Tea can own os.Stdin exclusively.
|
|
// SetReadDeadline on the dup'd non-blocking fd forces the blocked Read
|
|
// to return immediately, so the goroutine enters its sleep loop without
|
|
// eating the user's next keystroke.
|
|
tm.stdinPaused.Store(true)
|
|
if tm.stdinPollFd != nil {
|
|
_ = tm.stdinPollFd.SetReadDeadline(time.Now())
|
|
}
|
|
time.Sleep(20 * time.Millisecond) // let goroutine wake and enter sleep loop
|
|
for {
|
|
select {
|
|
case <-tm.stdinCh:
|
|
default:
|
|
goto drained
|
|
}
|
|
}
|
|
drained:
|
|
|
|
spec, err := newTabFn(ctx, tm.emitNotice, tm.buildTabBarString, nil, seed)
|
|
|
|
if tm.stdinPollFd != nil {
|
|
_ = tm.stdinPollFd.SetReadDeadline(time.Time{})
|
|
}
|
|
tm.stdinPaused.Store(false)
|
|
|
|
if termState != nil {
|
|
_, _ = term.MakeRaw(int(os.Stdin.Fd()))
|
|
}
|
|
tm.writeString("\x1b[r\x1b[?6l")
|
|
|
|
return spec, err
|
|
}
|
|
|
|
func prefersFullscreenChooser() bool {
|
|
return strings.EqualFold(strings.TrimSpace(os.Getenv("TERM_PROGRAM")), "Apple_Terminal")
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// SGR sanitizer — rewrites colon sub-parameters for vt10x compatibility
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// sanitizeSGR rewrites SGR sequences that contain colon-separated sub-parameters
|
|
// (e.g. \x1b[4:3m for curly underline, \x1b[38:2::100:150:200m for true-color)
|
|
// into semicolon-separated equivalents that vt10x's CSI parser can handle.
|
|
//
|
|
// vt10x uses strconv.Atoi on each semicolon-delimited parameter. A colon in any
|
|
// parameter causes Atoi to fail and the parser to BREAK, discarding all remaining
|
|
// parameters in the sequence — including colors that follow.
|
|
func sanitizeSGR(data []byte) []byte {
|
|
// Fast path: no colons at all → nothing to fix.
|
|
if !bytes.ContainsRune(data, ':') {
|
|
return data
|
|
}
|
|
|
|
result := make([]byte, 0, len(data)+32)
|
|
i := 0
|
|
for i < len(data) {
|
|
if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '[' {
|
|
// Found CSI start — find the final byte.
|
|
start := i
|
|
j := i + 2
|
|
for j < len(data) && data[j] < 0x40 {
|
|
j++
|
|
}
|
|
if j >= len(data) {
|
|
// Partial CSI at end of buffer — pass through as-is.
|
|
result = append(result, data[i:]...)
|
|
return result
|
|
}
|
|
if data[j] == 'm' && bytes.ContainsRune(data[i+2:j], ':') {
|
|
// SGR with colon params — rewrite.
|
|
result = append(result, 0x1b, '[')
|
|
result = append(result, rewriteSGRParams(data[i+2:j])...)
|
|
result = append(result, 'm')
|
|
} else {
|
|
// Not SGR or no colons — pass through.
|
|
result = append(result, data[start:j+1]...)
|
|
}
|
|
i = j + 1
|
|
} else {
|
|
result = append(result, data[i])
|
|
i++
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Frame-based rendering
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// renderLoop drives frame rendering at ~30fps. It reads the active tab's VT
|
|
// emulator state and composites it with the tab bar into a single atomic frame.
|
|
// A slower status tick (~2s) forces redraws so tab status indicators update
|
|
// when output stops (e.g. generating → waiting transition).
|
|
func (tm *TabManager) renderLoop() {
|
|
frameTicker := time.NewTicker(33 * time.Millisecond)
|
|
statusTicker := time.NewTicker(2 * time.Second)
|
|
defer frameTicker.Stop()
|
|
defer statusTicker.Stop()
|
|
for {
|
|
select {
|
|
case <-frameTicker.C:
|
|
tm.expireNoticeIfNeeded()
|
|
tm.renderFrame()
|
|
case <-statusTicker.C:
|
|
tm.mu.Lock()
|
|
tm.needsRender = true
|
|
tm.mu.Unlock()
|
|
case <-tm.closeCh:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (tm *TabManager) expireNoticeIfNeeded() {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
if tm.noticeText == "" || tm.noticeSticky || tm.noticeUntil.IsZero() || time.Now().Before(tm.noticeUntil) {
|
|
return
|
|
}
|
|
tm.noticeText = ""
|
|
tm.noticeLevel = TabNoticeInfo
|
|
tm.noticeUntil = time.Time{}
|
|
tm.needsRender = true
|
|
}
|
|
|
|
// renderFrame composites the active tab's VT screen content with the tab bar
|
|
// into a single write, using synchronized output to prevent tearing.
|
|
func (tm *TabManager) renderFrame() {
|
|
tm.mu.Lock()
|
|
if !tm.needsRender || tm.paused {
|
|
tm.mu.Unlock()
|
|
return
|
|
}
|
|
tm.needsRender = false
|
|
if tm.activeIdx >= len(tm.tabs) {
|
|
tm.mu.Unlock()
|
|
return
|
|
}
|
|
tab := tm.tabs[tm.activeIdx]
|
|
if tab.vt == nil {
|
|
tm.mu.Unlock()
|
|
return
|
|
}
|
|
rows := int(tm.rows)
|
|
cols := int(tm.cols)
|
|
tm.mu.Unlock()
|
|
|
|
contentRows := rows - 1
|
|
if contentRows < 1 {
|
|
contentRows = 1
|
|
}
|
|
|
|
// Read screen state from VT emulator under its lock.
|
|
// When cursor is visible, save its position — this is the child's
|
|
// intended cursor location (after CUP + \x1b[?25h). We'll use this
|
|
// saved position for rendering even when a subsequent hide+content
|
|
// write has moved the live cursor to end-of-content.
|
|
tab.vt.Lock()
|
|
screenContent := renderVTScreen(tab.vt, cols, contentRows)
|
|
cursor := tab.vt.Cursor()
|
|
vtCursorVisible := tab.vt.CursorVisible()
|
|
vtMode := tab.vt.Mode()
|
|
tab.vt.Unlock()
|
|
|
|
curX, curY, showCursor := resolveRenderCursor(
|
|
tab,
|
|
cursor.X,
|
|
cursor.Y,
|
|
vtCursorVisible,
|
|
tab.cursorVisible.Load(),
|
|
)
|
|
tm.traceClaudeCursorf(tab, "render_decision vt_cursor=(%d,%d) vt_visible=%t raw_visible=%t saved_valid=%t saved=(%d,%d) render=(%d,%d) show=%t",
|
|
cursor.X, cursor.Y, vtCursorVisible, tab.cursorVisible.Load(),
|
|
tab.cursorSavedValid.Load(), tab.cursorSavedX.Load(), tab.cursorSavedY.Load(),
|
|
curX, curY, showCursor)
|
|
if tm.isCommandMode() && vtMode&vt10x.ModeMouseMask == 0 {
|
|
vtMode |= vt10x.ModeMouseX10
|
|
}
|
|
|
|
tabBar := tm.buildTabBarString()
|
|
|
|
// Composite the full frame
|
|
var frame strings.Builder
|
|
frame.Grow(len(screenContent) + len(tabBar) + 128)
|
|
frame.WriteString(tm.syncHostInputModes(vtMode))
|
|
frame.WriteString("\x1b[?2026h") // begin synchronized update
|
|
frame.WriteString("\x1b[?25l") // hide cursor during render
|
|
frame.WriteString("\x1b[r") // reset scroll region (clear any DECSTBM left by child/chooser)
|
|
frame.WriteString("\x1b[H") // cursor to home (top-left)
|
|
frame.WriteString(screenContent) // VT emulator content (rows-1 lines)
|
|
fmt.Fprintf(&frame, "\x1b[%d;1H", rows) // position on last row
|
|
frame.WriteString(tabBar) // tab bar
|
|
|
|
// Position cursor using the render-resolution policy for the active tab.
|
|
fmt.Fprintf(&frame, "\x1b[%d;%dH", curY+1, curX+1)
|
|
if showCursor {
|
|
if shape := tab.cursorShape.Load(); shape >= 0 {
|
|
fmt.Fprintf(&frame, "\x1b[%d q", shape)
|
|
}
|
|
frame.WriteString("\x1b[?25h")
|
|
}
|
|
frame.WriteString("\x1b[?2026l") // end synchronized update
|
|
|
|
tm.writeString(frame.String())
|
|
}
|
|
|
|
// VT attribute flags — mirrors unexported vt10x constants.
|
|
const (
|
|
vtAttrReverse int16 = 1 << 0
|
|
vtAttrUnderline int16 = 1 << 1
|
|
vtAttrBold int16 = 1 << 2
|
|
vtAttrItalic int16 = 1 << 4
|
|
vtAttrBlink int16 = 1 << 5
|
|
)
|
|
|
|
// renderVTScreen extracts styled content from a VT emulator's cell grid,
|
|
// producing ANSI-escaped output suitable for writing to the real terminal.
|
|
// The caller must hold the VT emulator's lock.
|
|
func renderVTScreen(vt vt10x.View, cols, rows int) string {
|
|
var b strings.Builder
|
|
b.Grow(cols * rows * 3)
|
|
|
|
vtCols, vtRows := vt.Size()
|
|
|
|
var prevFG, prevBG vt10x.Color
|
|
var prevMode int16
|
|
firstCell := true
|
|
|
|
for y := 0; y < rows; y++ {
|
|
if y > 0 {
|
|
b.WriteString("\x1b[0m\r\n")
|
|
prevFG, prevBG, prevMode = 0, 0, 0
|
|
firstCell = true
|
|
}
|
|
for x := 0; x < cols; x++ {
|
|
if y < vtRows && x < vtCols {
|
|
g := vt.Cell(x, y)
|
|
|
|
if firstCell || g.FG != prevFG || g.BG != prevBG || g.Mode != prevMode {
|
|
writeStyleSequence(&b, g)
|
|
prevFG, prevBG, prevMode = g.FG, g.BG, g.Mode
|
|
firstCell = false
|
|
}
|
|
|
|
ch := g.Char
|
|
if ch == 0 {
|
|
ch = ' '
|
|
}
|
|
b.WriteRune(ch)
|
|
} else {
|
|
// Outside VT grid — emit a blank with default style
|
|
if firstCell || prevFG != vt10x.DefaultFG || prevBG != vt10x.DefaultBG || prevMode != 0 {
|
|
b.WriteString("\x1b[0m")
|
|
prevFG, prevBG, prevMode = vt10x.DefaultFG, vt10x.DefaultBG, 0
|
|
firstCell = false
|
|
}
|
|
b.WriteByte(' ')
|
|
}
|
|
}
|
|
}
|
|
b.WriteString("\x1b[0m")
|
|
return b.String()
|
|
}
|
|
|
|
// writeStyleSequence emits an SGR reset + attribute/color sequence for a glyph.
|
|
// vt10x pre-applies reverse to stored colors, except the default-FG/default-BG
|
|
// case where a reverse-video space must still emit SGR 7 to stay visible.
|
|
func writeStyleSequence(b *strings.Builder, g vt10x.Glyph) {
|
|
b.WriteString("\x1b[0")
|
|
|
|
if g.Mode&vtAttrReverse != 0 && g.FG == vt10x.DefaultBG && g.BG == vt10x.DefaultFG {
|
|
b.WriteString(";7")
|
|
}
|
|
if g.Mode&vtAttrBold != 0 {
|
|
b.WriteString(";1")
|
|
}
|
|
if g.Mode&vtAttrItalic != 0 {
|
|
b.WriteString(";3")
|
|
}
|
|
if g.Mode&vtAttrUnderline != 0 {
|
|
b.WriteString(";4")
|
|
}
|
|
if g.Mode&vtAttrBlink != 0 {
|
|
b.WriteString(";5")
|
|
}
|
|
|
|
writeColor(b, g.FG, false)
|
|
writeColor(b, g.BG, true)
|
|
|
|
b.WriteByte('m')
|
|
}
|
|
|
|
// writeColor appends an SGR color parameter for a vt10x Color value.
|
|
func writeColor(b *strings.Builder, c vt10x.Color, bg bool) {
|
|
if c == vt10x.DefaultFG || c == vt10x.DefaultBG || c == vt10x.DefaultCursor {
|
|
return // default — omit, reset already handles it
|
|
}
|
|
if bg {
|
|
switch {
|
|
case c < 8:
|
|
fmt.Fprintf(b, ";%d", 40+c)
|
|
case c < 16:
|
|
fmt.Fprintf(b, ";%d", 100+c-8)
|
|
case c < 256:
|
|
fmt.Fprintf(b, ";48;5;%d", c)
|
|
default:
|
|
fmt.Fprintf(b, ";48;2;%d;%d;%d", (c>>16)&0xFF, (c>>8)&0xFF, c&0xFF)
|
|
}
|
|
} else {
|
|
switch {
|
|
case c < 8:
|
|
fmt.Fprintf(b, ";%d", 30+c)
|
|
case c < 16:
|
|
fmt.Fprintf(b, ";%d", 90+c-8)
|
|
case c < 256:
|
|
fmt.Fprintf(b, ";38;5;%d", c)
|
|
default:
|
|
fmt.Fprintf(b, ";38;2;%d;%d;%d", (c>>16)&0xFF, (c>>8)&0xFF, c&0xFF)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Tab bar rendering
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
const tabProgressWindow = 1500 * time.Millisecond
|
|
const tabProgressConsistencyWindow = 3 * time.Second
|
|
const tabStartingWindow = 5 * time.Second
|
|
const tabCtrlCExitWindow = 1500 * time.Millisecond
|
|
const tabTypingWindow = time.Second
|
|
|
|
// buildTabBarString returns the styled tab bar content (one row of ANSI-styled
|
|
// text) without any cursor positioning. Used by both renderFrame and drawTabBar.
|
|
// tabStatusEmoji returns a status indicator emoji for the given tab.
|
|
// Priority: ⏳ startup > 🔔 notification > 🧠 progress > ✅ idle/ready.
|
|
// The tab's VT lock must NOT be held by the caller.
|
|
func tabStatusEmoji(tab *Tab) string {
|
|
now := time.Now()
|
|
|
|
if tab.exited.Load() || tab.vt == nil {
|
|
return ""
|
|
}
|
|
if !tab.startedAt.IsZero() && now.Sub(tab.startedAt) < tabStartingWindow {
|
|
return "⏳"
|
|
}
|
|
tab.statusMu.RLock()
|
|
bell := tab.bell
|
|
tab.statusMu.RUnlock()
|
|
if bell {
|
|
return "🔔"
|
|
}
|
|
if tabHasRecentScreenProgress(tab, now) && !tabHasRecentUserInput(tab, now) {
|
|
return "🧠"
|
|
}
|
|
return "✅"
|
|
}
|
|
|
|
func noteTabScreenChange(tab *Tab, now time.Time) bool {
|
|
if tab == nil || tab.vt == nil {
|
|
return false
|
|
}
|
|
|
|
hash := hashVTScreen(tab.vt)
|
|
tab.statusMu.Lock()
|
|
defer tab.statusMu.Unlock()
|
|
if hash == tab.screenHash {
|
|
return false
|
|
}
|
|
|
|
if tab.screenHash != 0 {
|
|
tab.prevScreenChange = tab.lastScreenChange
|
|
}
|
|
tab.screenHash = hash
|
|
tab.lastScreenChange = now
|
|
return true
|
|
}
|
|
|
|
func tabHasRecentScreenProgress(tab *Tab, now time.Time) bool {
|
|
if tab == nil {
|
|
return false
|
|
}
|
|
tab.statusMu.RLock()
|
|
defer tab.statusMu.RUnlock()
|
|
if tab.lastScreenChange.IsZero() || tab.prevScreenChange.IsZero() {
|
|
return false
|
|
}
|
|
return now.Sub(tab.lastScreenChange) <= tabProgressWindow &&
|
|
now.Sub(tab.prevScreenChange) <= tabProgressConsistencyWindow
|
|
}
|
|
|
|
func noteTabUserInput(tab *Tab, now time.Time) {
|
|
if tab == nil {
|
|
return
|
|
}
|
|
tab.lastUserInputAt.Store(now.UnixNano())
|
|
}
|
|
|
|
func tabHasRecentUserInput(tab *Tab, now time.Time) bool {
|
|
if tab == nil {
|
|
return false
|
|
}
|
|
|
|
last := tab.lastUserInputAt.Load()
|
|
if last == 0 {
|
|
return false
|
|
}
|
|
|
|
return now.Sub(time.Unix(0, last)) <= tabTypingWindow
|
|
}
|
|
|
|
func hashVTScreen(vt vt10x.Terminal) uint64 {
|
|
if vt == nil {
|
|
return 0
|
|
}
|
|
|
|
vt.Lock()
|
|
defer vt.Unlock()
|
|
|
|
h := fnv.New64a()
|
|
cols, rows := vt.Size()
|
|
cursor := vt.Cursor()
|
|
cursorVisible := vt.CursorVisible()
|
|
|
|
var buf [8]byte
|
|
writeUint64 := func(v uint64) {
|
|
for i := 0; i < 8; i++ {
|
|
buf[i] = byte(v >> (8 * i))
|
|
}
|
|
_, _ = h.Write(buf[:])
|
|
}
|
|
|
|
writeUint64(uint64(cols))
|
|
writeUint64(uint64(rows))
|
|
writeUint64(uint64(cursor.X))
|
|
writeUint64(uint64(cursor.Y))
|
|
if cursorVisible {
|
|
_, _ = h.Write([]byte{1})
|
|
} else {
|
|
_, _ = h.Write([]byte{0})
|
|
}
|
|
|
|
for y := 0; y < rows; y++ {
|
|
for x := 0; x < cols; x++ {
|
|
g := vt.Cell(x, y)
|
|
writeUint64(uint64(g.Char))
|
|
writeUint64(uint64(g.FG))
|
|
writeUint64(uint64(g.BG))
|
|
writeUint64(uint64(g.Mode))
|
|
}
|
|
}
|
|
|
|
return h.Sum64()
|
|
}
|
|
|
|
func (tm *TabManager) buildTabBarString() string {
|
|
tm.mu.Lock()
|
|
tabs := make([]*Tab, len(tm.tabs))
|
|
copy(tabs, tm.tabs)
|
|
active := tm.activeIdx
|
|
cols := int(tm.cols)
|
|
cmdMode := tm.commandMode
|
|
noticeText := tm.noticeText
|
|
noticeLevel := tm.noticeLevel
|
|
tm.mu.Unlock()
|
|
|
|
var b strings.Builder
|
|
|
|
// Background color: blue in command mode, dark gray normally.
|
|
bg := "\x1b[48;5;236m"
|
|
if cmdMode {
|
|
bg = "\x1b[48;5;22m"
|
|
}
|
|
if noticeText != "" && noticeLevel == TabNoticeError {
|
|
bg = "\x1b[48;5;88m"
|
|
}
|
|
reset := "\x1b[0m"
|
|
versionLabel := ""
|
|
if tm.version != "" && noticeText == "" {
|
|
versionLabel = " " + normalizedVersionLabel(tm.version) + " "
|
|
}
|
|
|
|
b.WriteString("\x1b[2K")
|
|
b.WriteString(bg)
|
|
b.WriteString(tabBarBrandString(bg))
|
|
|
|
if len(tabs) == 0 {
|
|
activeBg := "\x1b[0;48;5;238;1;37m"
|
|
if cmdMode {
|
|
activeBg = "\x1b[0;48;5;28;1;37m"
|
|
}
|
|
b.WriteString(activeBg + " Home ")
|
|
} else {
|
|
for i, tab := range tabs {
|
|
status := tabStatusEmoji(tab)
|
|
var label string
|
|
if status != "" {
|
|
label = fmt.Sprintf(" %s %d:%s ", status, i+1, tab.label)
|
|
} else {
|
|
label = fmt.Sprintf(" %d:%s ", i+1, tab.label)
|
|
}
|
|
if i == active {
|
|
activeBg := "\x1b[0;48;5;238;1;37m"
|
|
if cmdMode {
|
|
activeBg = "\x1b[0;48;5;28;1;37m"
|
|
}
|
|
b.WriteString(activeBg)
|
|
} else if tab.exited.Load() {
|
|
b.WriteString(bg + "\x1b[2;37m")
|
|
} else {
|
|
b.WriteString(bg + "\x1b[37m")
|
|
}
|
|
b.WriteString(label)
|
|
}
|
|
}
|
|
|
|
hint := " ^B tab mode "
|
|
if noticeText != "" {
|
|
if noticeLevel == TabNoticeError {
|
|
hint = " error: " + noticeText + " Esc: clear "
|
|
} else {
|
|
hint = " " + noticeText + " "
|
|
}
|
|
} else if cmdMode {
|
|
hint = " n:new e:edit session x:close h/l:move 1-9:jump"
|
|
if tm.updateVersion != "" {
|
|
hint += " u:update"
|
|
}
|
|
hint += " Esc:resume "
|
|
}
|
|
used := tm.tabBarContentWidth(tabs) + len(hint) + len(versionLabel)
|
|
if cols > used {
|
|
b.WriteString(bg + strings.Repeat(" ", cols-used))
|
|
}
|
|
b.WriteString(bg + "\x1b[2m" + hint)
|
|
if versionLabel != "" {
|
|
b.WriteString(bg + "\x1b[36m" + versionLabel)
|
|
}
|
|
b.WriteString(reset)
|
|
return b.String()
|
|
}
|
|
|
|
// drawTabBar renders the tab bar on the last terminal row as a standalone
|
|
// operation (with cursor save/restore). Used during the chooser flow and
|
|
// initial startup before the render loop takes over.
|
|
func (tm *TabManager) drawTabBar() {
|
|
tm.mu.Lock()
|
|
rows := tm.rows
|
|
tm.mu.Unlock()
|
|
|
|
content := tm.buildTabBarString()
|
|
|
|
var b strings.Builder
|
|
b.WriteString(tm.syncHostInputModes(0))
|
|
b.WriteString("\x1b[r") // reset scroll region before absolute positioning
|
|
b.WriteString("\x1b[?6l") // absolute origin mode
|
|
b.WriteString("\x1b[s")
|
|
fmt.Fprintf(&b, "\x1b[%d;1H", rows)
|
|
b.WriteString(content)
|
|
b.WriteString("\x1b[u")
|
|
|
|
tm.writeString(b.String())
|
|
}
|
|
|
|
// handleResize updates dimensions, resizes all VT emulators and PTYs,
|
|
// and triggers a re-render.
|
|
func (tm *TabManager) handleResize() {
|
|
c, r, err := term.GetSize(int(os.Stdout.Fd()))
|
|
if err != nil {
|
|
return
|
|
}
|
|
cols, rows := normalizeTerminalSize(c, r)
|
|
|
|
tm.mu.Lock()
|
|
tm.rows, tm.cols = rows, cols
|
|
tabs := make([]*Tab, len(tm.tabs))
|
|
copy(tabs, tm.tabs)
|
|
tm.needsRender = true
|
|
tm.mu.Unlock()
|
|
|
|
contentRows := int(tm.contentRows())
|
|
sz := &pty.Winsize{Rows: uint16(contentRows), Cols: cols}
|
|
for _, tab := range tabs {
|
|
if tab.vt != nil {
|
|
// Resize is self-locking
|
|
tab.vt.Resize(int(cols), contentRows)
|
|
}
|
|
if !tab.exited.Load() && tab.ptmx != nil {
|
|
pty.Setsize(tab.ptmx, sz)
|
|
}
|
|
}
|
|
// When paused (chooser active), the chooser's View includes the tab bar
|
|
// via TabBarLine — Bubble Tea redraws it as part of its own render cycle.
|
|
}
|
|
|
|
func (tm *TabManager) redrawActiveTab() {
|
|
tm.mu.Lock()
|
|
tm.needsRender = true
|
|
tm.mu.Unlock()
|
|
}
|
|
|
|
func (tm *TabManager) redrawTab(tab *Tab) {
|
|
tm.mu.Lock()
|
|
tm.needsRender = true
|
|
tm.mu.Unlock()
|
|
}
|
|
|
|
func (tm *TabManager) tabBarContentWidth(tabs []*Tab) int {
|
|
used := tabBarBrandWidth()
|
|
if len(tabs) == 0 {
|
|
return used + len(" Home ")
|
|
}
|
|
for i, tab := range tabs {
|
|
status := tabStatusEmoji(tab)
|
|
if status != "" {
|
|
used += 4 // emoji (2 cells) + space + space
|
|
}
|
|
used += len(fmt.Sprintf(" %d:%s ", i+1, tab.label))
|
|
}
|
|
return used
|
|
}
|
|
|
|
func tabBarTabIndexAtColumn(tabs []*Tab, col int) int {
|
|
if len(tabs) == 0 || col <= tabBarBrandWidth() {
|
|
return -1
|
|
}
|
|
|
|
pos := tabBarBrandWidth() + 1
|
|
for i, tab := range tabs {
|
|
width := len(fmt.Sprintf(" %d:%s ", i+1, tab.label))
|
|
if status := tabStatusEmoji(tab); status != "" {
|
|
width += 4
|
|
}
|
|
if col >= pos && col < pos+width {
|
|
return i
|
|
}
|
|
pos += width
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
func tabBarBrandString(bg string) string {
|
|
var b strings.Builder
|
|
b.WriteString(bg)
|
|
b.WriteString(" ")
|
|
b.WriteString("\x1b[1;36mBifrost CLI ")
|
|
return b.String()
|
|
}
|
|
|
|
func usesAppDrawnCursor(tab *Tab) bool {
|
|
if tab == nil {
|
|
return false
|
|
}
|
|
return strings.HasPrefix(tab.label, "Claude Code") || strings.HasPrefix(tab.label, "Gemini CLI")
|
|
}
|
|
|
|
func resolveRenderCursor(tab *Tab, vtCursorX, vtCursorY int, vtCursorVisible, rawCursorVisible bool) (int, int, bool) {
|
|
if usesAppDrawnCursor(tab) {
|
|
// Claude and Gemini render their own prompt cursor in-band (for
|
|
// example via a reverse-video space) and can park the real terminal
|
|
// cursor elsewhere after redraws. Rendering a separate host cursor on
|
|
// top of that VT content causes the visible block to drift or pick up
|
|
// the wrong terminal-default color.
|
|
return vtCursorX, vtCursorY, false
|
|
}
|
|
|
|
showCursor := vtCursorVisible || rawCursorVisible
|
|
curX, curY := vtCursorX, vtCursorY
|
|
if !vtCursorVisible && rawCursorVisible && tab != nil && tab.cursorSavedValid.Load() {
|
|
curX = int(tab.cursorSavedX.Load())
|
|
curY = int(tab.cursorSavedY.Load())
|
|
}
|
|
return curX, curY, showCursor
|
|
}
|
|
|
|
func tabBarBrandWidth() int {
|
|
return 1 + len("Bifrost CLI ")
|
|
}
|
|
|
|
func (tm *TabManager) initCursorTrace() {
|
|
path := strings.TrimSpace(os.Getenv("BIFROST_CLAUDE_CURSOR_TRACE"))
|
|
if path == "" {
|
|
return
|
|
}
|
|
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
if tm.stderr != nil {
|
|
fmt.Fprintf(tm.stderr, "bifrost: failed to open Claude cursor trace %q: %v\n", path, err)
|
|
}
|
|
return
|
|
}
|
|
tm.cursorTrace = f
|
|
tm.traceSystemf("cursor_trace_enabled path=%q", path)
|
|
}
|
|
|
|
func (tm *TabManager) closeCursorTrace() {
|
|
if tm == nil || tm.cursorTrace == nil {
|
|
return
|
|
}
|
|
tm.cursorTraceMu.Lock()
|
|
defer tm.cursorTraceMu.Unlock()
|
|
_ = tm.cursorTrace.Close()
|
|
tm.cursorTrace = nil
|
|
}
|
|
|
|
func (tm *TabManager) traceSystemf(format string, args ...any) {
|
|
if tm == nil || tm.cursorTrace == nil {
|
|
return
|
|
}
|
|
tm.cursorTraceMu.Lock()
|
|
defer tm.cursorTraceMu.Unlock()
|
|
fmt.Fprintf(tm.cursorTrace, "%s system "+format+"\n", append([]any{time.Now().Format(time.RFC3339Nano)}, args...)...)
|
|
}
|
|
|
|
func (tm *TabManager) traceClaudeCursorf(tab *Tab, format string, args ...any) {
|
|
if tm == nil || tm.cursorTrace == nil || !strings.HasPrefix(tab.label, "Claude Code") {
|
|
return
|
|
}
|
|
tm.cursorTraceMu.Lock()
|
|
defer tm.cursorTraceMu.Unlock()
|
|
prefixArgs := []any{time.Now().Format(time.RFC3339Nano), tab.label}
|
|
fmt.Fprintf(tm.cursorTrace, "%s tab=%q "+format+"\n", append(prefixArgs, args...)...)
|
|
}
|
|
|
|
func (tm *TabManager) traceClaudeCursorBytes(tab *Tab, kind string, data []byte) {
|
|
if tm == nil || tm.cursorTrace == nil || !strings.HasPrefix(tab.label, "Claude Code") {
|
|
return
|
|
}
|
|
tm.traceClaudeCursorf(tab, "%s %s", kind, summarizeTraceBytes(data))
|
|
}
|
|
|
|
func summarizeTraceBytes(data []byte) string {
|
|
if len(data) == 0 {
|
|
return "len=0"
|
|
}
|
|
|
|
const maxDump = 96
|
|
dump := data
|
|
if len(dump) > maxDump {
|
|
dump = dump[:maxDump]
|
|
}
|
|
|
|
suffix := ""
|
|
if len(dump) < len(data) {
|
|
suffix = "..."
|
|
}
|
|
|
|
return fmt.Sprintf("len=%d sample_hex=%x%s sample_q=%q%s", len(data), dump, suffix, dump, suffix)
|
|
}
|
|
|
|
func normalizedVersionLabel(version string) string {
|
|
version = strings.TrimSpace(version)
|
|
if version == "" {
|
|
return ""
|
|
}
|
|
if strings.HasPrefix(version, "v") {
|
|
return version
|
|
}
|
|
return "v" + version
|
|
}
|
|
|
|
func (tm *TabManager) contentRows() uint16 {
|
|
if tm.rows <= 1 {
|
|
return 1
|
|
}
|
|
return tm.rows - 1 // bottom tab bar
|
|
}
|
|
|
|
func normalizeTerminalSize(cols, rows int) (uint16, uint16) {
|
|
if cols < 20 {
|
|
cols = 20
|
|
}
|
|
if rows < 2 {
|
|
rows = 2
|
|
}
|
|
return uint16(cols), uint16(rows)
|
|
}
|
|
|
|
func (tm *TabManager) writeBytes(data []byte) {
|
|
tm.outputMu.Lock()
|
|
defer tm.outputMu.Unlock()
|
|
_, _ = tm.stdout.Write(data)
|
|
}
|
|
|
|
func (tm *TabManager) writeString(s string) {
|
|
tm.outputMu.Lock()
|
|
defer tm.outputMu.Unlock()
|
|
_, _ = io.WriteString(tm.stdout, s)
|
|
}
|
|
|
|
func (tm *TabManager) resetHostInputModes() {
|
|
seq := tm.syncHostInputModes(0) + hostKeyboardResetSequence() + hostCursorResetSequence()
|
|
if seq == "" {
|
|
return
|
|
}
|
|
tm.writeString(seq)
|
|
}
|
|
|
|
func (tm *TabManager) syncHostInputModes(mode vt10x.ModeFlag) string {
|
|
desired := mode & hostTrackedVTModeMask
|
|
|
|
tm.mu.Lock()
|
|
current := tm.hostVTMode
|
|
if current == desired {
|
|
tm.mu.Unlock()
|
|
return ""
|
|
}
|
|
tm.hostVTMode = desired
|
|
tm.mu.Unlock()
|
|
|
|
return hostInputModeSequence(desired)
|
|
}
|
|
|
|
func hostInputModeSequence(mode vt10x.ModeFlag) string {
|
|
var b strings.Builder
|
|
|
|
// Always clear tracked modes first so switching between mouse protocols
|
|
// can't leave stale tracking enabled on the host terminal.
|
|
b.WriteString("\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l\x1b[?9l")
|
|
|
|
if mode&vt10x.ModeFocus != 0 {
|
|
b.WriteString("\x1b[?1004h")
|
|
}
|
|
if mode&vt10x.ModeMouseSgr != 0 {
|
|
b.WriteString("\x1b[?1006h")
|
|
}
|
|
|
|
switch {
|
|
case mode&vt10x.ModeMouseMany != 0:
|
|
b.WriteString("\x1b[?1003h")
|
|
case mode&vt10x.ModeMouseMotion != 0:
|
|
b.WriteString("\x1b[?1002h")
|
|
case mode&vt10x.ModeMouseButton != 0:
|
|
b.WriteString("\x1b[?1000h")
|
|
case mode&vt10x.ModeMouseX10 != 0:
|
|
b.WriteString("\x1b[?9h")
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func hostKeyboardResetSequence() string {
|
|
// Pop kitty keyboard protocol enhancements before handing control back to
|
|
// Bubble Tea or the host shell. This fixes chooser key handling without
|
|
// spraying broader terminal-keyboard mode resets into every terminal.
|
|
return "\x1b[<u"
|
|
}
|
|
|
|
func hostCursorResetSequence() string {
|
|
// Restore a visible default cursor in case the child CLI hid it or left a
|
|
// custom DECSCUSR shape behind.
|
|
return "\x1b[0 q\x1b[?25h"
|
|
}
|