diff --git a/code/go.mod b/code/go.mod index ae7dbf2..e5d5cdf 100644 --- a/code/go.mod +++ b/code/go.mod @@ -8,6 +8,7 @@ require ( github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/creack/pty v1.1.24 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect diff --git a/code/go.sum b/code/go.sum index 06d0305..c07be0f 100644 --- a/code/go.sum +++ b/code/go.sum @@ -4,6 +4,8 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/code/internal/server/console.go b/code/internal/server/console.go index 03f6276..442133a 100644 --- a/code/internal/server/console.go +++ b/code/internal/server/console.go @@ -1,10 +1,8 @@ package server import ( - "bufio" "encoding/json" "fmt" - "io" "net/http" "os" "os/exec" @@ -154,52 +152,28 @@ func cleanEnv() []string { return env } -// runCommand executes a command and streams output to listeners. +// runCommand executes a command in a PTY and streams output to listeners. func (s *Server) runCommand(session *sessionState, command, execID string) { - // Build the claude command cmd := exec.Command("claude", "--permission-mode", "dontAsk", "-p", command) cmd.Dir = s.projectRoot() cmd.Env = cleanEnv() - stdout, err := cmd.StdoutPipe() + ptmx, err := startPTY(cmd) if err != nil { - s.sendToSession(session, "[greška: "+err.Error()+"]") - s.finishSession(session, execID, "error") - return - } - - stderr, err := cmd.StderrPipe() - if err != nil { - s.sendToSession(session, "[greška: "+err.Error()+"]") + s.sendToSession(session, "[greška pri pokretanju: "+err.Error()+"]") s.finishSession(session, execID, "error") return } + defer ptmx.Close() session.mu.Lock() session.cmd = cmd session.mu.Unlock() - if err := cmd.Start(); err != nil { - s.sendToSession(session, "[greška pri pokretanju: "+err.Error()+"]") - s.finishSession(session, execID, "error") - return - } - - // Read stdout and stderr concurrently - var wg sync.WaitGroup - wg.Add(2) - - go func() { - defer wg.Done() - s.streamReader(session, stdout) - }() - - go func() { - defer wg.Done() - s.streamReader(session, stderr) - }() - - wg.Wait() + // Read PTY output and send to session + readPTY(ptmx, func(line string) { + s.sendToSession(session, line) + }) err = cmd.Wait() status := "done" @@ -212,16 +186,6 @@ func (s *Server) runCommand(session *sessionState, command, execID string) { s.finishSession(session, execID, status) } -// streamReader reads from a reader line by line and sends to session. -func (s *Server) streamReader(session *sessionState, reader io.Reader) { - scanner := bufio.NewScanner(reader) - scanner.Buffer(make([]byte, 64*1024), 256*1024) - for scanner.Scan() { - line := scanner.Text() - s.sendToSession(session, line) - } -} - // sendToSession sends a line to all listeners and stores in output buffer. func (s *Server) sendToSession(session *sessionState, line string) { session.mu.Lock() diff --git a/code/internal/server/pty.go b/code/internal/server/pty.go new file mode 100644 index 0000000..ad90afa --- /dev/null +++ b/code/internal/server/pty.go @@ -0,0 +1,71 @@ +package server + +import ( + "io" + "os" + "os/exec" + "regexp" + + "github.com/creack/pty" +) + +// ansiRegex matches ANSI escape sequences for stripping terminal formatting. +var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?\x07|\x1b\[[\?0-9;]*[a-zA-Z]`) + +// stripAnsi removes ANSI escape codes from a string. +func stripAnsi(s string) string { + return ansiRegex.ReplaceAllString(s, "") +} + +// startPTY starts a command in a pseudo-terminal and returns the PTY master fd. +// The caller is responsible for closing the returned *os.File. +func startPTY(cmd *exec.Cmd) (*os.File, error) { + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 40, Cols: 120}) + if err != nil { + return nil, err + } + return ptmx, nil +} + +// readPTY reads from a PTY master and calls sendLine for each chunk of text. +// It splits on newlines so each SSE event is one line. +func readPTY(ptmx io.Reader, sendLine func(string)) { + buf := make([]byte, 4096) + var partial string + for { + n, err := ptmx.Read(buf) + if n > 0 { + text := partial + stripAnsi(string(buf[:n])) + partial = "" + + // Split into lines, keep partial for next read + for { + idx := -1 + for i, b := range []byte(text) { + if b == '\n' || b == '\r' { + idx = i + break + } + } + if idx < 0 { + partial = text + break + } + line := text[:idx] + // Skip empty lines from \r\n sequences + if line != "" { + sendLine(line) + } + // Skip past the newline character(s) + text = text[idx+1:] + } + } + if err != nil { + // Send remaining partial text + if partial != "" { + sendLine(partial) + } + break + } + } +} diff --git a/code/internal/server/server_test.go b/code/internal/server/server_test.go index 0fa1aef..d980130 100644 --- a/code/internal/server/server_test.go +++ b/code/internal/server/server_test.go @@ -1675,6 +1675,130 @@ func TestDocsPage_HasFullHeightLayout(t *testing.T) { } } +// --- T24: PTY tests --- + +func TestStripAnsi(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"hello", "hello"}, + {"\x1b[32mgreen\x1b[0m", "green"}, + {"\x1b[1;31mbold red\x1b[0m", "bold red"}, + {"\x1b[?25l\x1b[?25h", ""}, + {"no \x1b[4munderline\x1b[24m here", "no underline here"}, + {"\x1b]0;title\x07text", "text"}, + } + + for _, tt := range tests { + got := stripAnsi(tt.input) + if got != tt.expected { + t.Errorf("stripAnsi(%q) = %q, want %q", tt.input, got, tt.expected) + } + } +} + +func TestReadPTY_SplitsLines(t *testing.T) { + r, w, _ := os.Pipe() + + var lines []string + done := make(chan bool) + + go func() { + readPTY(r, func(line string) { + lines = append(lines, line) + }) + done <- true + }() + + w.WriteString("line1\nline2\nline3\n") + w.Close() + <-done + + if len(lines) != 3 { + t.Fatalf("expected 3 lines, got %d: %v", len(lines), lines) + } + if lines[0] != "line1" || lines[1] != "line2" || lines[2] != "line3" { + t.Errorf("unexpected lines: %v", lines) + } +} + +func TestReadPTY_StripsAnsi(t *testing.T) { + r, w, _ := os.Pipe() + + var lines []string + done := make(chan bool) + + go func() { + readPTY(r, func(line string) { + lines = append(lines, line) + }) + done <- true + }() + + w.WriteString("\x1b[32mcolored\x1b[0m\n") + w.Close() + <-done + + if len(lines) != 1 { + t.Fatalf("expected 1 line, got %d", len(lines)) + } + if lines[0] != "colored" { + t.Errorf("expected 'colored', got %q", lines[0]) + } +} + +func TestReadPTY_HandlesPartialChunks(t *testing.T) { + r, w, _ := os.Pipe() + + var lines []string + done := make(chan bool) + + go func() { + readPTY(r, func(line string) { + lines = append(lines, line) + }) + done <- true + }() + + // Write partial, then complete + w.WriteString("partial") + w.Close() + <-done + + if len(lines) != 1 { + t.Fatalf("expected 1 line for partial, got %d: %v", len(lines), lines) + } + if lines[0] != "partial" { + t.Errorf("expected 'partial', got %q", lines[0]) + } +} + +func TestReadPTY_HandlesCarriageReturn(t *testing.T) { + r, w, _ := os.Pipe() + + var lines []string + done := make(chan bool) + + go func() { + readPTY(r, func(line string) { + lines = append(lines, line) + }) + done <- true + }() + + w.WriteString("line1\r\nline2\r\n") + w.Close() + <-done + + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d: %v", len(lines), lines) + } + if lines[0] != "line1" || lines[1] != "line2" { + t.Errorf("unexpected lines: %v", lines) + } +} + func containsStr(s, substr string) bool { return len(s) >= len(substr) && findStr(s, substr) } diff --git a/code/internal/server/submit.go b/code/internal/server/submit.go index aacf3bc..da39119 100644 --- a/code/internal/server/submit.go +++ b/code/internal/server/submit.go @@ -3,9 +3,7 @@ package server import ( - "bufio" "fmt" - "io" "net/http" "os" "os/exec" @@ -154,64 +152,33 @@ func (s *Server) handleChatSubmit(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"chat_id": chatID}) } -// runChatCommand executes claude CLI and streams output to chat listeners. +// runChatCommand executes claude CLI in a PTY and streams output to chat listeners. func (s *Server) runChatCommand(chat *chatState, prompt string) { cmd := exec.Command("claude", "--permission-mode", "dontAsk", "-p", prompt) cmd.Dir = s.projectRoot() cmd.Env = cleanEnv() - stdout, err := cmd.StdoutPipe() + ptmx, err := startPTY(cmd) if err != nil { - sendChatLine(chat, "[greška: "+err.Error()+"]") - finishChat(chat) - return - } - - stderr, err := cmd.StderrPipe() - if err != nil { - sendChatLine(chat, "[greška: "+err.Error()+"]") + sendChatLine(chat, "[greška pri pokretanju: "+err.Error()+"]") finishChat(chat) return } + defer ptmx.Close() chat.mu.Lock() chat.cmd = cmd chat.mu.Unlock() - if err := cmd.Start(); err != nil { - sendChatLine(chat, "[greška pri pokretanju: "+err.Error()+"]") - finishChat(chat) - return - } + // Read PTY output and send to chat + readPTY(ptmx, func(line string) { + sendChatLine(chat, line) + }) - // Read stdout and stderr concurrently - var wg sync.WaitGroup - wg.Add(2) - - go func() { - defer wg.Done() - chatStreamReader(chat, stdout) - }() - - go func() { - defer wg.Done() - chatStreamReader(chat, stderr) - }() - - wg.Wait() cmd.Wait() finishChat(chat) } -// chatStreamReader reads from a reader line by line and sends to chat listeners. -func chatStreamReader(chat *chatState, reader io.Reader) { - scanner := bufio.NewScanner(reader) - scanner.Buffer(make([]byte, 64*1024), 256*1024) - for scanner.Scan() { - sendChatLine(chat, scanner.Text()) - } -} - // sendChatLine sends a line to all chat listeners and stores in output buffer. func sendChatLine(chat *chatState, line string) { chat.mu.Lock()