- Nova pty_session.go: RingBuffer, consolePTYSession, spawnConsolePTY - Nova ws.go: WebSocket handler za PTY bidirekcioni I/O - console.go: koristi consolePTYSession umesto starih pipe-ova - console.html: xterm.js 5.5.0 CDN, FitAddon, WebLinksAddon - Podrška za resize, binarni podaci, replay buffer (1MB) - 8 novih testova (RingBuffer + xterm konzola) — ukupno 179 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
216 lines
4.6 KiB
Go
216 lines
4.6 KiB
Go
package server
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/creack/pty"
|
|
)
|
|
|
|
const (
|
|
outputBufferSize = 1024 * 1024 // 1MB ring buffer for replay
|
|
)
|
|
|
|
// RingBuffer is a fixed-size circular buffer for terminal output.
|
|
type RingBuffer struct {
|
|
data []byte
|
|
size int
|
|
pos int
|
|
full bool
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// NewRingBuffer creates a new ring buffer with the given size.
|
|
func NewRingBuffer(size int) *RingBuffer {
|
|
return &RingBuffer{data: make([]byte, size), size: size}
|
|
}
|
|
|
|
// Write appends data to the ring buffer.
|
|
func (rb *RingBuffer) Write(p []byte) {
|
|
rb.mu.Lock()
|
|
defer rb.mu.Unlock()
|
|
for _, b := range p {
|
|
rb.data[rb.pos] = b
|
|
rb.pos++
|
|
if rb.pos >= rb.size {
|
|
rb.pos = 0
|
|
rb.full = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bytes returns the buffer contents in correct order.
|
|
func (rb *RingBuffer) Bytes() []byte {
|
|
rb.mu.Lock()
|
|
defer rb.mu.Unlock()
|
|
if !rb.full {
|
|
result := make([]byte, rb.pos)
|
|
copy(result, rb.data[:rb.pos])
|
|
return result
|
|
}
|
|
result := make([]byte, rb.size)
|
|
n := copy(result, rb.data[rb.pos:])
|
|
copy(result[n:], rb.data[:rb.pos])
|
|
return result
|
|
}
|
|
|
|
// Reset clears the buffer.
|
|
func (rb *RingBuffer) Reset() {
|
|
rb.mu.Lock()
|
|
defer rb.mu.Unlock()
|
|
rb.pos = 0
|
|
rb.full = false
|
|
}
|
|
|
|
// consolePTYSession manages a single claude CLI running in a pseudo-terminal.
|
|
type consolePTYSession struct {
|
|
ID string
|
|
Ptmx *os.File
|
|
Cmd *exec.Cmd
|
|
buffer *RingBuffer
|
|
subscribers map[string]chan []byte
|
|
mu sync.Mutex
|
|
done chan struct{}
|
|
lastActive time.Time
|
|
}
|
|
|
|
// spawnConsolePTY starts a new claude CLI in a PTY for the console.
|
|
func spawnConsolePTY(projectDir, prompt string) (*consolePTYSession, error) {
|
|
cmd := exec.Command("claude", "--permission-mode", "dontAsk", "-p", prompt)
|
|
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("pty-%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)
|
|
for {
|
|
n, err := s.Ptmx.Read(buf)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if n == 0 {
|
|
continue
|
|
}
|
|
data := make([]byte, n)
|
|
copy(data, buf[:n])
|
|
|
|
s.buffer.Write(data)
|
|
|
|
s.mu.Lock()
|
|
s.lastActive = time.Now()
|
|
for _, ch := range s.subscribers {
|
|
select {
|
|
case ch <- data:
|
|
default:
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
}
|
|
|
|
// waitExit waits for the CLI process to exit and signals done.
|
|
func (s *consolePTYSession) waitExit() {
|
|
if s.Cmd.Process != nil {
|
|
s.Cmd.Wait()
|
|
}
|
|
close(s.done)
|
|
}
|
|
|
|
// Subscribe adds a subscriber for PTY output.
|
|
func (s *consolePTYSession) Subscribe(id string) chan []byte {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
ch := make(chan []byte, 256)
|
|
s.subscribers[id] = ch
|
|
return ch
|
|
}
|
|
|
|
// Unsubscribe removes a subscriber.
|
|
func (s *consolePTYSession) Unsubscribe(id string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if ch, ok := s.subscribers[id]; ok {
|
|
close(ch)
|
|
delete(s.subscribers, id)
|
|
}
|
|
}
|
|
|
|
// Resize changes the PTY terminal size.
|
|
func (s *consolePTYSession) Resize(rows, cols uint16) error {
|
|
return pty.Setsize(s.Ptmx, &pty.Winsize{Rows: rows, Cols: cols})
|
|
}
|
|
|
|
// WriteInput sends keyboard input to the PTY.
|
|
func (s *consolePTYSession) WriteInput(data []byte) (int, error) {
|
|
s.mu.Lock()
|
|
s.lastActive = time.Now()
|
|
s.mu.Unlock()
|
|
return s.Ptmx.Write(data)
|
|
}
|
|
|
|
// GetBuffer returns the ring buffer contents for replay.
|
|
func (s *consolePTYSession) GetBuffer() []byte {
|
|
return s.buffer.Bytes()
|
|
}
|
|
|
|
// Done returns a channel that closes when the process exits.
|
|
func (s *consolePTYSession) Done() <-chan struct{} {
|
|
return s.done
|
|
}
|
|
|
|
// Close terminates the PTY session.
|
|
func (s *consolePTYSession) Close() {
|
|
s.mu.Lock()
|
|
for id, ch := range s.subscribers {
|
|
close(ch)
|
|
delete(s.subscribers, id)
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
s.Ptmx.Close()
|
|
if s.Cmd.Process != nil {
|
|
s.Cmd.Process.Kill()
|
|
}
|
|
}
|
|
|
|
// cleanEnvForPTY returns environment with proper terminal settings.
|
|
func cleanEnvForPTY() []string {
|
|
var env []string
|
|
for _, e := range os.Environ() {
|
|
if strings.HasPrefix(e, "CLAUDECODE=") ||
|
|
strings.HasPrefix(e, "CLAUDE_CODE_ENTRYPOINT=") ||
|
|
strings.HasPrefix(e, "TERM=") ||
|
|
strings.HasPrefix(e, "COLORTERM=") {
|
|
continue
|
|
}
|
|
env = append(env, e)
|
|
}
|
|
env = append(env, "TERM=xterm-256color", "COLORTERM=truecolor")
|
|
return env
|
|
}
|