T24: PTY za konzolu i operater chat — real-time streaming

Konzola i operater chat sada koriste pseudo-terminal (PTY) umesto
pipe-a. Claude CLI detektuje terminal i šalje output odmah umesto
da bufferuje. ANSI escape sekvence se uklanjaju pre slanja kroz SSE.

Novi fajl: pty.go (startPTY, readPTY, stripAnsi)
Biblioteka: github.com/creack/pty v1.1.24
5 novih testova za PTY funkcionalnost.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
djuka 2026-02-20 15:13:13 +00:00
parent 41beccab7e
commit 003650df24
6 changed files with 214 additions and 85 deletions

View File

@ -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

View File

@ -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=

View File

@ -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()

View File

@ -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
}
}
}

View File

@ -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)
}

View File

@ -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()