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:
parent
41beccab7e
commit
003650df24
@ -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
|
||||
|
||||
@ -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=
|
||||
|
||||
@ -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()
|
||||
|
||||
71
code/internal/server/pty.go
Normal file
71
code/internal/server/pty.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user