feat: remove npm warns from terminal + fix terminal render on refresh (#104)

This commit is contained in:
Carlos Florêncio
2026-01-26 23:52:28 +00:00
committed by GitHub
parent ec77511f26
commit 9463ddac9f
5 changed files with 495 additions and 81 deletions
@@ -92,6 +92,9 @@ type interactiveProcess struct {
directOutput DirectOutputWriter
directOutputMu sync.RWMutex
// WebSocket tracking - tracks whether a WebSocket is actively connected
hasActiveWebSocket bool
// Lifecycle
stopOnce sync.Once
stopSignal chan struct{}
@@ -112,6 +115,13 @@ type StatusCallback func(status *types.ProcessStatusUpdate)
// AgentStateCallback is called when agent TUI state changes (working, waiting, etc.).
type AgentStateCallback func(sessionID string, state AgentState)
// sessionWebSocket tracks a WebSocket connection at the session level.
// This allows the WebSocket to survive process restarts.
type sessionWebSocket struct {
writer DirectOutputWriter
mu sync.RWMutex
}
// InteractiveRunner manages interactive PTY-based processes with stdin support.
type InteractiveRunner struct {
logger *logger.Logger
@@ -124,6 +134,10 @@ type InteractiveRunner struct {
mu sync.RWMutex
processes map[string]*interactiveProcess
// Session-level WebSocket tracking - survives process restarts
sessionWsMu sync.RWMutex
sessionWs map[string]*sessionWebSocket
}
// NewInteractiveRunner creates a new interactive process runner.
@@ -133,6 +147,7 @@ func NewInteractiveRunner(workspaceTracker *WorkspaceTracker, log *logger.Logger
workspaceTracker: workspaceTracker,
bufferMaxBytes: bufferMaxBytes,
processes: make(map[string]*interactiveProcess),
sessionWs: make(map[string]*sessionWebSocket),
}
}
@@ -662,13 +677,21 @@ func (r *InteractiveRunner) readOutput(proc *interactiveProcess) {
}
}
// Always buffer output for scrollback restoration on reconnect
chunk := ProcessOutputChunk{
Stream: "stdout",
Data: dataStr,
Timestamp: time.Now().UTC(),
}
proc.buffer.append(chunk)
// Check if we have a direct output writer (binary WebSocket mode)
proc.directOutputMu.RLock()
directWriter := proc.directOutput
proc.directOutputMu.RUnlock()
if directWriter != nil {
// Direct mode: write raw bytes to the WebSocket, skip event bus
// Direct mode: write raw bytes to the WebSocket
if _, writeErr := directWriter.Write(data); writeErr != nil {
r.logger.Debug("direct output write error",
zap.String("process_id", proc.info.ID),
@@ -676,13 +699,7 @@ func (r *InteractiveRunner) readOutput(proc *interactiveProcess) {
// Don't return - the writer might have closed but process continues
}
} else {
// Normal mode: buffer and stream output via event bus
chunk := ProcessOutputChunk{
Stream: "stdout",
Data: dataStr,
Timestamp: time.Now().UTC(),
}
proc.buffer.append(chunk)
// No WebSocket connected: also publish via event bus
r.publishOutput(proc, chunk)
}
@@ -922,6 +939,7 @@ func (p *interactiveProcess) snapshot(includeOutput bool) InteractiveProcessInfo
// SetDirectOutput sets a direct output writer for a process.
// When set, PTY output bypasses the event bus and goes directly to this writer.
// This is used for the dedicated binary WebSocket in passthrough mode.
// Also tracks the WebSocket at the session level to survive process restarts.
// Returns error if process not found.
func (r *InteractiveRunner) SetDirectOutput(processID string, writer DirectOutputWriter) error {
proc, ok := r.get(processID)
@@ -929,36 +947,119 @@ func (r *InteractiveRunner) SetDirectOutput(processID string, writer DirectOutpu
return fmt.Errorf("process not found: %s", processID)
}
sessionID := proc.info.SessionID
proc.directOutputMu.Lock()
proc.directOutput = writer
proc.hasActiveWebSocket = true
proc.directOutputMu.Unlock()
// Also track at session level for restart support
r.sessionWsMu.Lock()
r.sessionWs[sessionID] = &sessionWebSocket{writer: writer}
r.sessionWsMu.Unlock()
r.logger.Info("direct output set for process",
zap.String("process_id", processID),
zap.String("session_id", proc.info.SessionID))
zap.String("session_id", sessionID))
return nil
}
// ClearDirectOutput clears the direct output writer for a process.
// Output will return to the normal event bus path.
// Also clears the session-level WebSocket tracking.
func (r *InteractiveRunner) ClearDirectOutput(processID string) error {
proc, ok := r.get(processID)
if !ok {
return fmt.Errorf("process not found: %s", processID)
// Process may have been deleted - still try to clear session tracking
r.logger.Debug("process not found during ClearDirectOutput, trying to clear by session",
zap.String("process_id", processID))
return nil
}
sessionID := proc.info.SessionID
proc.directOutputMu.Lock()
proc.directOutput = nil
proc.hasActiveWebSocket = false
proc.directOutputMu.Unlock()
// Also clear session-level tracking
r.sessionWsMu.Lock()
delete(r.sessionWs, sessionID)
r.sessionWsMu.Unlock()
r.logger.Info("direct output cleared for process",
zap.String("process_id", processID),
zap.String("session_id", proc.info.SessionID))
zap.String("session_id", sessionID))
return nil
}
// ClearDirectOutputBySession clears the direct output for a session.
// This is used when the terminal WebSocket disconnects.
func (r *InteractiveRunner) ClearDirectOutputBySession(sessionID string) {
// Clear session-level tracking
r.sessionWsMu.Lock()
delete(r.sessionWs, sessionID)
r.sessionWsMu.Unlock()
// Also clear from any process with this session
r.mu.RLock()
for _, proc := range r.processes {
if proc.info.SessionID == sessionID {
proc.directOutputMu.Lock()
proc.directOutput = nil
proc.hasActiveWebSocket = false
proc.directOutputMu.Unlock()
}
}
r.mu.RUnlock()
r.logger.Info("direct output cleared for session",
zap.String("session_id", sessionID))
}
// ConnectSessionWebSocket connects an existing session WebSocket to a process.
// This is called when a new process starts for a session that already has an active WebSocket.
// Returns true if a WebSocket was connected.
func (r *InteractiveRunner) ConnectSessionWebSocket(processID string) bool {
proc, ok := r.get(processID)
if !ok {
return false
}
sessionID := proc.info.SessionID
r.sessionWsMu.RLock()
sessWs, exists := r.sessionWs[sessionID]
r.sessionWsMu.RUnlock()
if !exists || sessWs == nil {
return false
}
sessWs.mu.RLock()
writer := sessWs.writer
sessWs.mu.RUnlock()
if writer == nil {
return false
}
proc.directOutputMu.Lock()
proc.directOutput = writer
proc.hasActiveWebSocket = true
proc.directOutputMu.Unlock()
r.logger.Info("connected existing session WebSocket to new process",
zap.String("process_id", processID),
zap.String("session_id", sessionID))
return true
}
// GetPtyWriter returns a writer for sending input to the PTY.
// This is used for the dedicated binary WebSocket in passthrough mode.
func (r *InteractiveRunner) GetPtyWriter(processID string) (io.Writer, error) {
@@ -981,6 +1082,113 @@ func (r *InteractiveRunner) GetPtyWriter(processID string) (io.Writer, error) {
return proc.ptmx, nil
}
// GetPtyWriterBySession returns a writer for sending input to the PTY for a session.
// This is used to reconnect after a process restart.
func (r *InteractiveRunner) GetPtyWriterBySession(sessionID string) (io.Writer, string, error) {
r.mu.RLock()
var proc *interactiveProcess
for _, p := range r.processes {
if p.info.SessionID == sessionID {
proc = p
break
}
}
r.mu.RUnlock()
if proc == nil {
return nil, "", fmt.Errorf("no process found for session: %s", sessionID)
}
proc.mu.Lock()
defer proc.mu.Unlock()
if !proc.started {
return nil, proc.info.ID, fmt.Errorf("process not started yet - waiting for terminal dimensions")
}
if proc.ptmx == nil {
return nil, proc.info.ID, fmt.Errorf("PTY not available")
}
return proc.ptmx, proc.info.ID, nil
}
// HasActiveWebSocket checks if a process has an active WebSocket connection.
// This is used to determine if auto-restart should be attempted on process exit.
func (r *InteractiveRunner) HasActiveWebSocket(processID string) bool {
proc, ok := r.get(processID)
if !ok {
return false
}
proc.directOutputMu.RLock()
defer proc.directOutputMu.RUnlock()
return proc.hasActiveWebSocket
}
// HasActiveWebSocketBySession checks if a session has an active WebSocket connection.
// Uses session-level tracking which survives process restarts.
func (r *InteractiveRunner) HasActiveWebSocketBySession(sessionID string) bool {
r.sessionWsMu.RLock()
sessWs, exists := r.sessionWs[sessionID]
r.sessionWsMu.RUnlock()
if !exists || sessWs == nil {
return false
}
sessWs.mu.RLock()
hasWriter := sessWs.writer != nil
sessWs.mu.RUnlock()
return hasWriter
}
// WriteToDirectOutput writes data directly to the WebSocket output for a process.
// This is used to send messages like restart notifications to the terminal.
// Returns error if process not found or no direct output is set.
func (r *InteractiveRunner) WriteToDirectOutput(processID string, data []byte) error {
proc, ok := r.get(processID)
if !ok {
return fmt.Errorf("process not found: %s", processID)
}
proc.directOutputMu.RLock()
directWriter := proc.directOutput
proc.directOutputMu.RUnlock()
if directWriter == nil {
return fmt.Errorf("no direct output writer set for process: %s", processID)
}
_, err := directWriter.Write(data)
return err
}
// WriteToDirectOutputBySession writes data directly to the WebSocket output for a session.
// This is used to send messages like restart notifications to the terminal.
// Uses session-level tracking which survives process restarts.
func (r *InteractiveRunner) WriteToDirectOutputBySession(sessionID string, data []byte) error {
r.sessionWsMu.RLock()
sessWs, exists := r.sessionWs[sessionID]
r.sessionWsMu.RUnlock()
if !exists || sessWs == nil {
return fmt.Errorf("no WebSocket found for session: %s", sessionID)
}
sessWs.mu.RLock()
writer := sessWs.writer
sessWs.mu.RUnlock()
if writer == nil {
return fmt.Errorf("no direct output writer set for session: %s", sessionID)
}
_, err := writer.Write(data)
return err
}
// containsDSRQuery checks if data contains a Device Status Report (cursor position) query.
// DSR query is: ESC [ 6 n or ESC [ ? 6 n
func containsDSRQuery(data []byte) bool {