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 v1.14.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // 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/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // 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/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 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@ -154,52 +152,28 @@ func cleanEnv() []string {
|
|||||||
return env
|
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) {
|
func (s *Server) runCommand(session *sessionState, command, execID string) {
|
||||||
// Build the claude command
|
|
||||||
cmd := exec.Command("claude", "--permission-mode", "dontAsk", "-p", command)
|
cmd := exec.Command("claude", "--permission-mode", "dontAsk", "-p", command)
|
||||||
cmd.Dir = s.projectRoot()
|
cmd.Dir = s.projectRoot()
|
||||||
cmd.Env = cleanEnv()
|
cmd.Env = cleanEnv()
|
||||||
|
|
||||||
stdout, err := cmd.StdoutPipe()
|
ptmx, err := startPTY(cmd)
|
||||||
if err != nil {
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
stderr, err := cmd.StderrPipe()
|
|
||||||
if err != nil {
|
|
||||||
s.sendToSession(session, "[greška: "+err.Error()+"]")
|
|
||||||
s.finishSession(session, execID, "error")
|
s.finishSession(session, execID, "error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer ptmx.Close()
|
||||||
|
|
||||||
session.mu.Lock()
|
session.mu.Lock()
|
||||||
session.cmd = cmd
|
session.cmd = cmd
|
||||||
session.mu.Unlock()
|
session.mu.Unlock()
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
// Read PTY output and send to session
|
||||||
s.sendToSession(session, "[greška pri pokretanju: "+err.Error()+"]")
|
readPTY(ptmx, func(line string) {
|
||||||
s.finishSession(session, execID, "error")
|
s.sendToSession(session, line)
|
||||||
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()
|
|
||||||
|
|
||||||
err = cmd.Wait()
|
err = cmd.Wait()
|
||||||
status := "done"
|
status := "done"
|
||||||
@ -212,16 +186,6 @@ func (s *Server) runCommand(session *sessionState, command, execID string) {
|
|||||||
s.finishSession(session, execID, status)
|
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.
|
// sendToSession sends a line to all listeners and stores in output buffer.
|
||||||
func (s *Server) sendToSession(session *sessionState, line string) {
|
func (s *Server) sendToSession(session *sessionState, line string) {
|
||||||
session.mu.Lock()
|
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 {
|
func containsStr(s, substr string) bool {
|
||||||
return len(s) >= len(substr) && findStr(s, substr)
|
return len(s) >= len(substr) && findStr(s, substr)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,9 +3,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@ -154,64 +152,33 @@ func (s *Server) handleChatSubmit(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"chat_id": chatID})
|
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) {
|
func (s *Server) runChatCommand(chat *chatState, prompt string) {
|
||||||
cmd := exec.Command("claude", "--permission-mode", "dontAsk", "-p", prompt)
|
cmd := exec.Command("claude", "--permission-mode", "dontAsk", "-p", prompt)
|
||||||
cmd.Dir = s.projectRoot()
|
cmd.Dir = s.projectRoot()
|
||||||
cmd.Env = cleanEnv()
|
cmd.Env = cleanEnv()
|
||||||
|
|
||||||
stdout, err := cmd.StdoutPipe()
|
ptmx, err := startPTY(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendChatLine(chat, "[greška: "+err.Error()+"]")
|
sendChatLine(chat, "[greška pri pokretanju: "+err.Error()+"]")
|
||||||
finishChat(chat)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
stderr, err := cmd.StderrPipe()
|
|
||||||
if err != nil {
|
|
||||||
sendChatLine(chat, "[greška: "+err.Error()+"]")
|
|
||||||
finishChat(chat)
|
finishChat(chat)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer ptmx.Close()
|
||||||
|
|
||||||
chat.mu.Lock()
|
chat.mu.Lock()
|
||||||
chat.cmd = cmd
|
chat.cmd = cmd
|
||||||
chat.mu.Unlock()
|
chat.mu.Unlock()
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
// Read PTY output and send to chat
|
||||||
sendChatLine(chat, "[greška pri pokretanju: "+err.Error()+"]")
|
readPTY(ptmx, func(line string) {
|
||||||
finishChat(chat)
|
sendChatLine(chat, line)
|
||||||
return
|
})
|
||||||
}
|
|
||||||
|
|
||||||
// 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()
|
cmd.Wait()
|
||||||
finishChat(chat)
|
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.
|
// sendChatLine sends a line to all chat listeners and stores in output buffer.
|
||||||
func sendChatLine(chat *chatState, line string) {
|
func sendChatLine(chat *chatState, line string) {
|
||||||
chat.mu.Lock()
|
chat.mu.Lock()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user