From 932ffe520335ca814d74f4bd2d199b530a2f64e6 Mon Sep 17 00:00:00 2001 From: djuka Date: Sat, 21 Feb 2026 04:04:39 +0000 Subject: [PATCH] Konzola: interaktivni claude CLI + panic fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Svaka konzola sesija pokreće interaktivni claude (ne bash) - Fix panic: send on closed channel kad se WS diskonektuje - Tema: Claude Code boje (#0d1117 pozadina) - PTY readLoop logging za debug Co-Authored-By: Claude Opus 4.6 --- code/internal/server/pty_session.go | 37 ++++++++++++ code/internal/server/ws.go | 90 ++++++++--------------------- code/web/templates/console.html | 63 +++++--------------- 3 files changed, 76 insertions(+), 114 deletions(-) diff --git a/code/internal/server/pty_session.go b/code/internal/server/pty_session.go index 20399aa..6e05530 100644 --- a/code/internal/server/pty_session.go +++ b/code/internal/server/pty_session.go @@ -2,6 +2,7 @@ package server import ( "fmt" + "log" "os" "os/exec" "strings" @@ -105,17 +106,47 @@ func spawnConsolePTY(projectDir, prompt string) (*consolePTYSession, error) { return sess, nil } +// spawnShellPTY starts an interactive claude CLI in a PTY for the console. +func spawnShellPTY(projectDir string) (*consolePTYSession, error) { + cmd := exec.Command("claude") + cmd.Dir = projectDir + cmd.Env = cleanEnvForPTY() + + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 24, Cols: 120}) + if err != nil { + return nil, fmt.Errorf("start pty: %w", err) + } + + sess := &consolePTYSession{ + ID: fmt.Sprintf("shell-%d", time.Now().UnixNano()), + Ptmx: ptmx, + Cmd: cmd, + buffer: NewRingBuffer(outputBufferSize), + subscribers: make(map[string]chan []byte), + done: make(chan struct{}), + lastActive: time.Now(), + } + + go sess.readLoop() + go sess.waitExit() + + return sess, nil +} + // readLoop reads PTY output, writes to ring buffer, and forwards to subscribers. func (s *consolePTYSession) readLoop() { buf := make([]byte, 4096) + totalBytes := 0 for { n, err := s.Ptmx.Read(buf) if err != nil { + log.Printf("PTY[%s]: readLoop ended (read %d bytes total, err: %v)", s.ID, totalBytes, err) return } if n == 0 { continue } + totalBytes += n data := make([]byte, n) copy(data, buf[:n]) @@ -123,6 +154,7 @@ func (s *consolePTYSession) readLoop() { s.mu.Lock() s.lastActive = time.Now() + subs := len(s.subscribers) for _, ch := range s.subscribers { select { case ch <- data: @@ -130,6 +162,11 @@ func (s *consolePTYSession) readLoop() { } } s.mu.Unlock() + + if totalBytes == n { + // First chunk — log it + log.Printf("PTY[%s]: first output (%d bytes, %d subscribers)", s.ID, n, subs) + } } } diff --git a/code/internal/server/ws.go b/code/internal/server/ws.go index 299ba3d..43393cc 100644 --- a/code/internal/server/ws.go +++ b/code/internal/server/ws.go @@ -24,7 +24,8 @@ type wsResizeMsg struct { Rows uint16 `json:"rows"` } -// handleConsoleWS handles WebSocket connections for console PTY sessions. +// handleConsoleWS handles WebSocket connections for console terminals. +// Each connection gets its own independent shell PTY session. func (s *Server) handleConsoleWS(c *gin.Context) { sessionNum := c.Param("session") if sessionNum != "1" && sessionNum != "2" { @@ -39,71 +40,24 @@ func (s *Server) handleConsoleWS(c *gin.Context) { } defer conn.Close() - idx := 0 - if sessionNum == "2" { - idx = 1 + log.Printf("WS[%s]: connected, spawning shell", sessionNum) + + // Spawn a fresh interactive shell for this connection + ptySess, err := spawnShellPTY(s.projectRoot()) + if err != nil { + log.Printf("WS[%s]: shell spawn error: %v", sessionNum, err) + conn.WriteMessage(websocket.TextMessage, + []byte(fmt.Sprintf("\r\n\033[31m[Greška: %v]\033[0m\r\n", err))) + return } + defer ptySess.Close() - log.Printf("WS[%s]: connected", sessionNum) + log.Printf("WS[%s]: shell started (PID %d)", sessionNum, ptySess.Cmd.Process.Pid) - session := s.console.getSession(idx) - - // Wait for PTY session to be available (it gets set when a command is executed) - session.mu.Lock() - ptySess := session.ptySess - session.mu.Unlock() - - if ptySess == nil { - log.Printf("WS[%s]: no PTY yet, polling...", sessionNum) - conn.WriteMessage(websocket.TextMessage, []byte("\r\n\033[33m[Čekam pokretanje sesije...]\033[0m\r\n")) - - // Poll for session to start (up to 30s) - ticker := time.NewTicker(300 * time.Millisecond) - defer ticker.Stop() - timeout := time.After(30 * time.Second) - for { - select { - case <-ticker.C: - session.mu.Lock() - ptySess = session.ptySess - session.mu.Unlock() - if ptySess != nil { - log.Printf("WS[%s]: PTY found after polling", sessionNum) - goto connected - } - case <-timeout: - log.Printf("WS[%s]: timeout waiting for PTY", sessionNum) - conn.WriteMessage(websocket.TextMessage, []byte("\r\n\033[31m[Timeout — sesija nije pokrenuta]\033[0m\r\n")) - return - case <-c.Request.Context().Done(): - log.Printf("WS[%s]: client disconnected while polling", sessionNum) - return - } - } - } - -connected: - log.Printf("WS[%s]: subscribing to PTY", sessionNum) subID := fmt.Sprintf("ws-%d", time.Now().UnixNano()) outputCh := ptySess.Subscribe(subID) defer ptySess.Unsubscribe(subID) - // Send buffered output for reconnect - buffered := ptySess.GetBuffer() - log.Printf("WS[%s]: sending buffer (%d bytes)", sessionNum, len(buffered)) - if len(buffered) > 0 { - conn.WriteMessage(websocket.BinaryMessage, buffered) - } - - // Check if already done - select { - case <-ptySess.Done(): - log.Printf("WS[%s]: PTY already done, sending buffer only", sessionNum) - conn.WriteMessage(websocket.TextMessage, []byte("\r\n\033[33m[Sesija završena]\033[0m\r\n")) - return - default: - } - // Serialized write channel writeCh := make(chan []byte, 256) writeDone := make(chan struct{}) @@ -126,17 +80,18 @@ connected: } }() + // Signal to stop goroutines when read pump exits + stopCh := make(chan struct{}) + // Watch for process exit go func() { - <-ptySess.Done() - log.Printf("WS[%s]: PTY process exited", sessionNum) select { - case writeCh <- []byte("\r\n\033[33m[Sesija završena]\033[0m\r\n"): - default: + case <-ptySess.Done(): + log.Printf("WS[%s]: shell exited", sessionNum) + conn.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, "done")) + case <-stopCh: } - time.Sleep(500 * time.Millisecond) - conn.WriteMessage(websocket.CloseMessage, - websocket.FormatCloseMessage(websocket.CloseNormalClosure, "done")) }() // WebSocket → PTY (read pump) @@ -160,11 +115,12 @@ connected: // Regular keyboard input → PTY if _, err := ptySess.WriteInput(msg); err != nil { - log.Printf("WS[%s]: PTY write error: %v", sessionNum, err) + log.Printf("WS[%s]: write error: %v", sessionNum, err) break } } + close(stopCh) close(writeCh) <-writeDone log.Printf("WS[%s]: disconnected", sessionNum) diff --git a/code/web/templates/console.html b/code/web/templates/console.html index c75cb9c..79cc649 100644 --- a/code/web/templates/console.html +++ b/code/web/templates/console.html @@ -38,8 +38,7 @@
🔧 Sesija 1 - idle - + connected
@@ -48,7 +47,6 @@
🔧 Sesija 2 idle -
@@ -62,9 +60,9 @@ // ── Terminal themes ────────────────────────────────── var TERM_THEMES = { dark: { - background: '#111', foreground: '#e0e0e0', cursor: '#e94560', cursorAccent: '#111', + background: '#0d1117', foreground: '#e0e0e0', cursor: '#e94560', cursorAccent: '#0d1117', selectionBackground: 'rgba(233,69,96,0.3)', - black: '#111', red: '#f44336', green: '#4caf50', yellow: '#ff9800', + black: '#0d1117', red: '#f44336', green: '#4caf50', yellow: '#ff9800', blue: '#2196f3', magenta: '#e94560', cyan: '#00bcd4', white: '#e0e0e0', brightBlack: '#6c6c80', brightRed: '#ff6b81', brightGreen: '#66bb6a', brightYellow: '#ffb74d', brightBlue: '#64b5f6', brightMagenta: '#ff6b81', brightCyan: '#4dd0e1', brightWhite: '#ffffff' @@ -132,7 +130,11 @@ function initTerminal(idx) { sessions[idx].fitAddon = fitAddon; sessions[idx].ws = null; - setTimeout(function() { fitAddon.fit(); }, 50); + setTimeout(function() { + fitAddon.fit(); + // Auto-connect WebSocket immediately + connectWS(idx); + }, 100); } // ── WebSocket connection ───────────────────────────── @@ -145,17 +147,13 @@ function connectWS(idx) { sess.ws = null; } - // Clear terminal for new session - sess.term.clear(); - sess.term.write('\x1b[33mPovezivanje na sesiju ' + num + '...\x1b[0m\r\n'); - var proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; var url = proto + '//' + location.host + '/console/ws/' + num; var ws = new WebSocket(url); ws.binaryType = 'arraybuffer'; ws.onopen = function() { - setSessionUI(num, 'running'); + setSessionUI(num, 'connected'); var term = sess.term; ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); term.focus(); @@ -171,35 +169,23 @@ function connectWS(idx) { ws.onclose = function() { sess.ws = null; - setSessionUI(num, 'idle'); + setSessionUI(num, 'disconnected'); }; ws.onerror = function() { sess.ws = null; - setSessionUI(num, 'idle'); + setSessionUI(num, 'error'); }; sess.ws = ws; } // ── UI helpers ─────────────────────────────────────── -function killSession(session) { - fetch('/console/kill/' + session, {method: 'POST'}) - .then(function() { - var idx = session - 1; - sessions[idx].term.write('\r\n\x1b[33m--- prekinuto ---\x1b[0m\r\n'); - if (sessions[idx].ws) { - sessions[idx].ws.close(); - sessions[idx].ws = null; - } - setSessionUI(session, 'idle'); - }); -} - function setSessionUI(session, status) { - document.getElementById('status-' + session).textContent = status; - document.getElementById('status-' + session).className = 'session-status session-' + status; - document.getElementById('kill-' + session).style.display = status === 'running' ? 'inline-block' : 'none'; + var el = document.getElementById('status-' + session); + el.textContent = status; + el.className = 'session-status'; + if (status === 'connected') el.className += ' session-running'; } function togglePanel2() { @@ -212,6 +198,7 @@ function togglePanel2() { initTerminal(1); } else { sessions[1].fitAddon.fit(); + if (!sessions[1].ws) connectWS(1); } } else { panel.style.display = 'none'; @@ -244,24 +231,6 @@ window.addEventListener('resize', function() { // ── Initialize ─────────────────────────────────────── initTerminal(0); - -// Auto-connect to running sessions on page load -fetch('/console/sessions') - .then(function(r) { return r.json(); }) - .then(function(data) { - for (var i = 0; i < data.length; i++) { - if (data[i].status === 'running') { - var idx = data[i].session - 1; - if (idx === 1 && !sessions[1].term) { - document.getElementById('panel-2').style.display = 'flex'; - document.getElementById('toggle-panel').textContent = '- Sesija 2'; - initTerminal(1); - } - connectWS(idx); - } - } - }) - .catch(function() {});