Zamena chat UI sa pravim terminalom (xterm.js + PTY)
Some checks failed
Tests / unit-tests (push) Failing after 43s
Some checks failed
Tests / unit-tests (push) Failing after 43s
- Dodat creack/pty za pseudo-terminal podršku - Claude CLI se pokreće u pravom PTY-ju (puni TUI, boje, Shift+Tab) - xterm.js u browseru renderuje terminal identično konzoli - WebSocket bridge: tastatura → PTY stdin, PTY stdout → terminal - Ring buffer (128KB) za replay pri reconnect-u - Automatski reconnect nakon 2 sekunde - PTY sesije žive nezavisno od browsera (60min idle timeout) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
93dbb33198
commit
adea7ca28d
4
go.mod
4
go.mod
@ -1,8 +1,10 @@
|
|||||||
module claude-web-chat
|
module claude-web-chat
|
||||||
|
|
||||||
go 1.23.6
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/creack/pty v1.1.24 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/yuin/goldmark v1.7.8 // indirect
|
github.com/yuin/goldmark v1.7.8 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
4
go.sum
4
go.sum
@ -1,4 +1,8 @@
|
|||||||
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
|||||||
63
main.go
63
main.go
@ -12,7 +12,7 @@ var (
|
|||||||
cfg *Config
|
cfg *Config
|
||||||
templates *TemplateRenderer
|
templates *TemplateRenderer
|
||||||
sessionMgr *SessionManager
|
sessionMgr *SessionManager
|
||||||
chatMgr *ChatSessionManager
|
ptyMgr *PTYSessionManager
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@ -29,10 +29,10 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sessionMgr = NewSessionManager(cfg.SessionSecret)
|
sessionMgr = NewSessionManager(cfg.SessionSecret)
|
||||||
chatMgr = NewChatSessionManager()
|
ptyMgr = NewPTYSessionManager()
|
||||||
defer chatMgr.Stop()
|
defer ptyMgr.Stop()
|
||||||
|
|
||||||
wsHandler := NewWSHandler(chatMgr)
|
wsHandler := NewTerminalHandler(ptyMgr)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
@ -49,6 +49,8 @@ func main() {
|
|||||||
mux.Handle("GET /chat/{project}", AuthMiddleware(sessionMgr, http.HandlerFunc(handleChat)))
|
mux.Handle("GET /chat/{project}", AuthMiddleware(sessionMgr, http.HandlerFunc(handleChat)))
|
||||||
mux.Handle("GET /ws", AuthMiddleware(sessionMgr, wsHandler))
|
mux.Handle("GET /ws", AuthMiddleware(sessionMgr, wsHandler))
|
||||||
mux.Handle("GET /api/file", AuthMiddleware(sessionMgr, http.HandlerFunc(handleFileAPI)))
|
mux.Handle("GET /api/file", AuthMiddleware(sessionMgr, http.HandlerFunc(handleFileAPI)))
|
||||||
|
mux.Handle("GET /change-password", AuthMiddleware(sessionMgr, http.HandlerFunc(handleChangePasswordPage)))
|
||||||
|
mux.Handle("POST /change-password", AuthMiddleware(sessionMgr, http.HandlerFunc(handleChangePassword)))
|
||||||
|
|
||||||
// Root redirect (exact match only)
|
// Root redirect (exact match only)
|
||||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -75,7 +77,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
username := r.FormValue("username")
|
username := r.FormValue("username")
|
||||||
password := r.FormValue("password")
|
password := r.FormValue("password")
|
||||||
|
|
||||||
if username != cfg.Username || password != cfg.Password {
|
if username != cfg.Username || !cfg.CheckPassword(password) {
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
templates.Render(w, "login.html", map[string]string{"Error": "Pogrešno korisničko ime ili lozinka"})
|
templates.Render(w, "login.html", map[string]string{"Error": "Pogrešno korisničko ime ili lozinka"})
|
||||||
return
|
return
|
||||||
@ -118,20 +120,57 @@ func handleChat(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
projectDir := filepath.Join(cfg.ProjectsPath, project)
|
projectDir := filepath.Join(cfg.ProjectsPath, project)
|
||||||
|
|
||||||
files, err := ListMarkdownFiles(projectDir)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("ListMarkdownFiles error: %v", err)
|
|
||||||
files = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"Project": project,
|
"Project": project,
|
||||||
"ProjectDir": projectDir,
|
"ProjectDir": projectDir,
|
||||||
"Files": files,
|
|
||||||
}
|
}
|
||||||
templates.Render(w, "chat.html", data)
|
templates.Render(w, "chat.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func handleChangePasswordPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
templates.Render(w, "change-password.html", map[string]string{"Error": "", "Success": ""})
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
currentPassword := r.FormValue("current_password")
|
||||||
|
newPassword := r.FormValue("new_password")
|
||||||
|
confirmPassword := r.FormValue("confirm_password")
|
||||||
|
|
||||||
|
data := map[string]string{"Error": "", "Success": ""}
|
||||||
|
|
||||||
|
if !cfg.CheckPassword(currentPassword) {
|
||||||
|
data["Error"] = "Pogrešna trenutna lozinka"
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
templates.Render(w, "change-password.html", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(newPassword) < 6 {
|
||||||
|
data["Error"] = "Nova lozinka mora imati najmanje 6 karaktera"
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
templates.Render(w, "change-password.html", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if newPassword != confirmPassword {
|
||||||
|
data["Error"] = "Nova lozinka i potvrda se ne poklapaju"
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
templates.Render(w, "change-password.html", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := cfg.SetPassword(newPassword); err != nil {
|
||||||
|
log.Printf("SetPassword error: %v", err)
|
||||||
|
data["Error"] = "Greška pri čuvanju lozinke"
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
templates.Render(w, "change-password.html", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data["Success"] = "Lozinka je uspešno promenjena"
|
||||||
|
templates.Render(w, "change-password.html", data)
|
||||||
|
}
|
||||||
|
|
||||||
func handleFileAPI(w http.ResponseWriter, r *http.Request) {
|
func handleFileAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
project := r.URL.Query().Get("project")
|
project := r.URL.Query().Get("project")
|
||||||
relPath := r.URL.Query().Get("path")
|
relPath := r.URL.Query().Get("path")
|
||||||
|
|||||||
30
markdown_test.go
Normal file
30
markdown_test.go
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRenderMD(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
contains string
|
||||||
|
}{
|
||||||
|
{"plain text", "Hello world", "<p>Hello world</p>"},
|
||||||
|
{"bold", "**bold**", "<strong>bold</strong>"},
|
||||||
|
{"code block", "```\ncode\n```", "<code>code"},
|
||||||
|
{"inline code", "`inline`", "<code>inline</code>"},
|
||||||
|
{"heading", "# Title", "<h1>Title</h1>"},
|
||||||
|
{"table", "| A | B |\n|---|---|\n| 1 | 2 |", "<table>"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := RenderMD(tt.input)
|
||||||
|
if !strings.Contains(result, tt.contains) {
|
||||||
|
t.Errorf("RenderMD(%q) = %q, want to contain %q", tt.input, result, tt.contains)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
312
pty_session.go
Normal file
312
pty_session.go
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/creack/pty"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
outputBufferSize = 128 * 1024 // 128KB ring buffer for replay
|
||||||
|
ptyIdleTimeout = 60 * time.Minute
|
||||||
|
ptyCleanupTick = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
// RingBuffer is a fixed-size circular buffer for terminal output.
|
||||||
|
type RingBuffer struct {
|
||||||
|
data []byte
|
||||||
|
size int
|
||||||
|
pos int
|
||||||
|
full bool
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRingBuffer(size int) *RingBuffer {
|
||||||
|
return &RingBuffer{data: make([]byte, size), size: size}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// PTYSession manages a single Claude CLI running in a pseudo-terminal.
|
||||||
|
type PTYSession struct {
|
||||||
|
ID string
|
||||||
|
ProjectDir string
|
||||||
|
Ptmx *os.File
|
||||||
|
Cmd *exec.Cmd
|
||||||
|
|
||||||
|
buffer *RingBuffer
|
||||||
|
subscribers map[string]chan []byte
|
||||||
|
mu sync.Mutex
|
||||||
|
done chan struct{}
|
||||||
|
lastActive time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpawnPTY starts a new Claude CLI in a pseudo-terminal.
|
||||||
|
func SpawnPTY(projectDir string) (*PTYSession, error) {
|
||||||
|
cmd := exec.Command("claude")
|
||||||
|
cmd.Dir = projectDir
|
||||||
|
|
||||||
|
// Filter env vars to prevent nested session detection
|
||||||
|
cmd.Env = filterEnvMulti(os.Environ(), []string{
|
||||||
|
"CLAUDECODE",
|
||||||
|
"CLAUDE_CODE_ENTRYPOINT",
|
||||||
|
})
|
||||||
|
|
||||||
|
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 24, Cols: 80})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("start pty: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := &PTYSession{
|
||||||
|
ID: fmt.Sprintf("%d", time.Now().UnixNano()),
|
||||||
|
ProjectDir: projectDir,
|
||||||
|
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 *PTYSession) 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 id, ch := range s.subscribers {
|
||||||
|
select {
|
||||||
|
case ch <- data:
|
||||||
|
default:
|
||||||
|
log.Printf("PTY subscriber %s slow, dropping data", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitExit waits for the CLI process to exit and signals done.
|
||||||
|
func (s *PTYSession) waitExit() {
|
||||||
|
if s.Cmd.Process != nil {
|
||||||
|
s.Cmd.Wait()
|
||||||
|
}
|
||||||
|
close(s.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe adds a subscriber for PTY output.
|
||||||
|
func (s *PTYSession) 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 *PTYSession) 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 *PTYSession) 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 *PTYSession) 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 *PTYSession) GetBuffer() []byte {
|
||||||
|
return s.buffer.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done returns a channel that closes when the process exits.
|
||||||
|
func (s *PTYSession) Done() <-chan struct{} {
|
||||||
|
return s.done
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close terminates the PTY session.
|
||||||
|
func (s *PTYSession) 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PTYSessionManager manages PTY sessions keyed by project name.
|
||||||
|
type PTYSessionManager struct {
|
||||||
|
sessions map[string]*PTYSession
|
||||||
|
mu sync.RWMutex
|
||||||
|
stopCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPTYSessionManager() *PTYSessionManager {
|
||||||
|
m := &PTYSessionManager{
|
||||||
|
sessions: make(map[string]*PTYSession),
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
go m.cleanup()
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrCreate returns an existing session or creates a new one.
|
||||||
|
func (m *PTYSessionManager) GetOrCreate(project, projectDir string) (*PTYSession, bool, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if sess, ok := m.sessions[project]; ok {
|
||||||
|
// Check if process is still alive
|
||||||
|
select {
|
||||||
|
case <-sess.Done():
|
||||||
|
// Process exited, remove and create new
|
||||||
|
sess.Close()
|
||||||
|
delete(m.sessions, project)
|
||||||
|
default:
|
||||||
|
sess.mu.Lock()
|
||||||
|
sess.lastActive = time.Now()
|
||||||
|
sess.mu.Unlock()
|
||||||
|
return sess, false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sess, err := SpawnPTY(projectDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.sessions[project] = sess
|
||||||
|
return sess, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove terminates and removes a session.
|
||||||
|
func (m *PTYSessionManager) Remove(project string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
sess, ok := m.sessions[project]
|
||||||
|
if ok {
|
||||||
|
delete(m.sessions, project)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
sess.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop shuts down the manager and all sessions.
|
||||||
|
func (m *PTYSessionManager) Stop() {
|
||||||
|
close(m.stopCh)
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
for id, sess := range m.sessions {
|
||||||
|
sess.Close()
|
||||||
|
delete(m.sessions, id)
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *PTYSessionManager) cleanup() {
|
||||||
|
ticker := time.NewTicker(ptyCleanupTick)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-m.stopCh:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
m.cleanupIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *PTYSessionManager) cleanupIdle() {
|
||||||
|
m.mu.Lock()
|
||||||
|
var toRemove []string
|
||||||
|
for id, sess := range m.sessions {
|
||||||
|
sess.mu.Lock()
|
||||||
|
idle := time.Since(sess.lastActive) > ptyIdleTimeout
|
||||||
|
sess.mu.Unlock()
|
||||||
|
|
||||||
|
// Also check if process has exited
|
||||||
|
exited := false
|
||||||
|
select {
|
||||||
|
case <-sess.Done():
|
||||||
|
exited = true
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
if idle || exited {
|
||||||
|
toRemove = append(toRemove, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
for _, id := range toRemove {
|
||||||
|
log.Printf("Cleaning up PTY session: %s", id)
|
||||||
|
m.Remove(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
74
pty_session_test.go
Normal file
74
pty_session_test.go
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRingBuffer(t *testing.T) {
|
||||||
|
t.Run("write and read", func(t *testing.T) {
|
||||||
|
rb := NewRingBuffer(10)
|
||||||
|
rb.Write([]byte("hello"))
|
||||||
|
got := rb.Bytes()
|
||||||
|
if string(got) != "hello" {
|
||||||
|
t.Errorf("got %q, want %q", got, "hello")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("wrap around", func(t *testing.T) {
|
||||||
|
rb := NewRingBuffer(5)
|
||||||
|
rb.Write([]byte("abcdefgh")) // wraps around
|
||||||
|
got := rb.Bytes()
|
||||||
|
if string(got) != "defgh" {
|
||||||
|
t.Errorf("got %q, want %q", got, "defgh")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("exact size", func(t *testing.T) {
|
||||||
|
rb := NewRingBuffer(5)
|
||||||
|
rb.Write([]byte("abcde"))
|
||||||
|
got := rb.Bytes()
|
||||||
|
if string(got) != "abcde" {
|
||||||
|
t.Errorf("got %q, want %q", got, "abcde")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty", func(t *testing.T) {
|
||||||
|
rb := NewRingBuffer(10)
|
||||||
|
got := rb.Bytes()
|
||||||
|
if len(got) != 0 {
|
||||||
|
t.Errorf("expected empty, got %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("multiple writes", func(t *testing.T) {
|
||||||
|
rb := NewRingBuffer(8)
|
||||||
|
rb.Write([]byte("abc"))
|
||||||
|
rb.Write([]byte("def"))
|
||||||
|
got := rb.Bytes()
|
||||||
|
if string(got) != "abcdef" {
|
||||||
|
t.Errorf("got %q, want %q", got, "abcdef")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("overflow multiple writes", func(t *testing.T) {
|
||||||
|
rb := NewRingBuffer(5)
|
||||||
|
rb.Write([]byte("abc"))
|
||||||
|
rb.Write([]byte("defgh")) // total 8, buffer 5
|
||||||
|
got := rb.Bytes()
|
||||||
|
if string(got) != "defgh" {
|
||||||
|
t.Errorf("got %q, want %q", got, "defgh")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPTYSessionManager(t *testing.T) {
|
||||||
|
t.Run("new manager has no sessions", func(t *testing.T) {
|
||||||
|
m := &PTYSessionManager{
|
||||||
|
sessions: make(map[string]*PTYSession),
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
if len(m.sessions) != 0 {
|
||||||
|
t.Error("expected empty sessions")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -3,146 +3,170 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Claude Web Chat — {{.Project}}</title>
|
<title>Claude — {{.Project}}</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
||||||
<script src="/static/htmx.min.js"></script>
|
<style>
|
||||||
<script src="/static/ws.js"></script>
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
background: #0d1117;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.terminal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
background: #161b22;
|
||||||
|
border-bottom: 1px solid #2a2a4a;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.terminal-header .title {
|
||||||
|
color: #e94560;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.terminal-header .controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.terminal-header .status {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #6c6c80;
|
||||||
|
}
|
||||||
|
.terminal-header .status.connected { color: #4caf50; }
|
||||||
|
.terminal-header a {
|
||||||
|
color: #a0a0b0;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.terminal-header a:hover { color: #e94560; }
|
||||||
|
#terminal-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.xterm { height: 100%; }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="chat-container">
|
<div class="terminal-header">
|
||||||
<!-- Sidebar -->
|
<span class="title">claude — {{.Project}}</span>
|
||||||
<div class="sidebar">
|
<div class="controls">
|
||||||
<div class="sidebar-header">
|
|
||||||
<h3>Fajlovi</h3>
|
|
||||||
<a href="/projects" style="font-size:0.85rem;">← Projekti</a>
|
|
||||||
</div>
|
|
||||||
<div class="sidebar-content" id="file-list">
|
|
||||||
{{range .Files}}
|
|
||||||
<a class="file-item" href="#" onclick="loadFile('{{.RelPath}}'); return false;">{{.Name}}</a>
|
|
||||||
{{end}}
|
|
||||||
{{if not .Files}}
|
|
||||||
<div style="padding: 1rem; color: var(--text-muted); font-size: 0.85rem;">
|
|
||||||
Nema .md fajlova
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Chat main area -->
|
|
||||||
<div class="chat-main">
|
|
||||||
<div class="chat-header">
|
|
||||||
<h2>{{.Project}}</h2>
|
|
||||||
<span id="ws-status" class="status">Povezivanje...</span>
|
<span id="ws-status" class="status">Povezivanje...</span>
|
||||||
|
<a href="/projects">← Projekti</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="terminal-container"></div>
|
||||||
|
|
||||||
<div class="chat-messages" id="chat-messages">
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
||||||
<!-- Messages will be appended here via OOB swap -->
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
||||||
</div>
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
||||||
|
|
||||||
<div id="typing-indicator"></div>
|
|
||||||
|
|
||||||
<div class="chat-input-area" hx-ext="ws" ws-connect="/ws?project={{.Project}}&project_dir={{.ProjectDir}}">
|
|
||||||
<div class="mode-bar">
|
|
||||||
<span class="mode-indicator" id="mode-indicator">
|
|
||||||
<span class="mode-label mode-active" id="mode-code" onclick="setMode('code')">Code</span>
|
|
||||||
<span class="mode-label" id="mode-plan" onclick="setMode('plan')">Plan</span>
|
|
||||||
</span>
|
|
||||||
<span class="mode-hint">Shift+Tab za promenu moda</span>
|
|
||||||
</div>
|
|
||||||
<form class="chat-input-form" ws-send>
|
|
||||||
<input type="hidden" name="mode" id="mode-input" value="code">
|
|
||||||
<textarea id="message-input" name="message" class="chat-input" placeholder="Pošalji poruku..." rows="1"></textarea>
|
|
||||||
<button type="submit" class="btn">Pošalji</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- File viewer overlay (hidden by default) -->
|
|
||||||
<div id="file-viewer" class="file-viewer hidden">
|
|
||||||
<div class="file-viewer-header">
|
|
||||||
<h3 id="file-viewer-title"></h3>
|
|
||||||
<button class="btn" onclick="closeFileViewer()">Zatvori</button>
|
|
||||||
</div>
|
|
||||||
<div class="file-viewer-content" id="file-viewer-content">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Mode switching
|
const statusEl = document.getElementById('ws-status');
|
||||||
let currentMode = 'code';
|
const container = document.getElementById('terminal-container');
|
||||||
|
|
||||||
function setMode(mode) {
|
const term = new Terminal({
|
||||||
currentMode = mode;
|
cursorBlink: true,
|
||||||
document.getElementById('mode-input').value = mode;
|
fontSize: 14,
|
||||||
const codeEl = document.getElementById('mode-code');
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace",
|
||||||
const planEl = document.getElementById('mode-plan');
|
theme: {
|
||||||
const textarea = document.getElementById('message-input');
|
background: '#0d1117',
|
||||||
if (mode === 'plan') {
|
foreground: '#e0e0e0',
|
||||||
codeEl.classList.remove('mode-active');
|
cursor: '#e94560',
|
||||||
planEl.classList.add('mode-active');
|
cursorAccent: '#0d1117',
|
||||||
textarea.placeholder = 'Plan mod — opiši šta treba analizirati...';
|
selectionBackground: 'rgba(233, 69, 96, 0.3)',
|
||||||
|
black: '#0d1117',
|
||||||
|
red: '#f44336',
|
||||||
|
green: '#4caf50',
|
||||||
|
yellow: '#ff9800',
|
||||||
|
blue: '#2196f3',
|
||||||
|
magenta: '#e94560',
|
||||||
|
cyan: '#00bcd4',
|
||||||
|
white: '#e0e0e0',
|
||||||
|
brightBlack: '#6c6c80',
|
||||||
|
brightRed: '#ff6b81',
|
||||||
|
brightGreen: '#66bb6a',
|
||||||
|
brightYellow: '#ffb74d',
|
||||||
|
brightBlue: '#64b5f6',
|
||||||
|
brightMagenta: '#ff6b81',
|
||||||
|
brightCyan: '#4dd0e1',
|
||||||
|
brightWhite: '#ffffff'
|
||||||
|
},
|
||||||
|
allowProposedApi: true,
|
||||||
|
scrollback: 10000,
|
||||||
|
convertEol: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fitAddon = new FitAddon.FitAddon();
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
term.loadAddon(new WebLinksAddon.WebLinksAddon());
|
||||||
|
|
||||||
|
term.open(container);
|
||||||
|
fitAddon.fit();
|
||||||
|
|
||||||
|
let ws;
|
||||||
|
let reconnectTimer;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
ws = new WebSocket(`${proto}//${location.host}/ws?project={{.Project}}&project_dir={{.ProjectDir}}`);
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
ws.onopen = function() {
|
||||||
|
statusEl.textContent = 'Povezan';
|
||||||
|
statusEl.className = 'status connected';
|
||||||
|
// Send initial terminal size
|
||||||
|
ws.send(JSON.stringify({type: 'resize', cols: term.cols, rows: term.rows}));
|
||||||
|
term.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = function(event) {
|
||||||
|
if (event.data instanceof ArrayBuffer) {
|
||||||
|
term.write(new Uint8Array(event.data));
|
||||||
} else {
|
} else {
|
||||||
planEl.classList.remove('mode-active');
|
term.write(event.data);
|
||||||
codeEl.classList.add('mode-active');
|
|
||||||
textarea.placeholder = 'Pošalji poruku...';
|
|
||||||
}
|
}
|
||||||
textarea.focus();
|
};
|
||||||
|
|
||||||
|
ws.onclose = function() {
|
||||||
|
statusEl.textContent = 'Nepovezan';
|
||||||
|
statusEl.className = 'status';
|
||||||
|
// Auto-reconnect after 2 seconds
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = setTimeout(connect, 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = function() {
|
||||||
|
statusEl.textContent = 'Greška';
|
||||||
|
statusEl.className = 'status';
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-resize textarea
|
// Send keyboard input to server
|
||||||
const textarea = document.getElementById('message-input');
|
term.onData(function(data) {
|
||||||
if (textarea) {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
textarea.addEventListener('input', function() {
|
ws.send(data);
|
||||||
this.style.height = 'auto';
|
|
||||||
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Submit on Enter (Shift+Enter for newline), Shift+Tab for mode switch
|
|
||||||
textarea.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Tab' && e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
setMode(currentMode === 'code' ? 'plan' : 'code');
|
|
||||||
} else if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.closest('form').dispatchEvent(new Event('submit', { bubbles: true }));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global Shift+Tab handler (works even when textarea not focused)
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Tab' && e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
setMode(currentMode === 'code' ? 'plan' : 'code');
|
|
||||||
}
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
closeFileViewer();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-scroll chat
|
// Send terminal resize to server
|
||||||
const chatMessages = document.getElementById('chat-messages');
|
term.onResize(function(size) {
|
||||||
const observer = new MutationObserver(function() {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
ws.send(JSON.stringify({type: 'resize', cols: size.cols, rows: size.rows}));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
observer.observe(chatMessages, { childList: true, subtree: true });
|
|
||||||
|
|
||||||
// File viewer
|
// Fit terminal on window resize
|
||||||
function loadFile(relPath) {
|
window.addEventListener('resize', function() {
|
||||||
fetch('/api/file?project={{.Project}}&path=' + encodeURIComponent(relPath))
|
fitAddon.fit();
|
||||||
.then(r => r.json())
|
});
|
||||||
.then(data => {
|
|
||||||
document.getElementById('file-viewer-title').textContent = data.name;
|
|
||||||
document.getElementById('file-viewer-content').innerHTML = data.html;
|
|
||||||
document.getElementById('file-viewer').classList.remove('hidden');
|
|
||||||
})
|
|
||||||
.catch(err => console.error('Error loading file:', err));
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeFileViewer() {
|
// Start connection
|
||||||
document.getElementById('file-viewer').classList.add('hidden');
|
connect();
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
304
ws.go
304
ws.go
@ -5,37 +5,34 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
var upgrader = websocket.Upgrader{
|
||||||
ReadBufferSize: 1024,
|
ReadBufferSize: 4096,
|
||||||
WriteBufferSize: 4096,
|
WriteBufferSize: 4096,
|
||||||
CheckOrigin: func(r *http.Request) bool { return true },
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
}
|
}
|
||||||
|
|
||||||
type wsMessage struct {
|
// resizeMsg is sent from the browser when the terminal size changes.
|
||||||
Message string `json:"message"`
|
type resizeMsg struct {
|
||||||
Mode string `json:"mode"` // "code" or "plan"
|
Type string `json:"type"`
|
||||||
|
Cols uint16 `json:"cols"`
|
||||||
|
Rows uint16 `json:"rows"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WSHandler handles WebSocket connections for chat.
|
// TerminalHandler handles WebSocket connections for terminal sessions.
|
||||||
type WSHandler struct {
|
type TerminalHandler struct {
|
||||||
sessionMgr *ChatSessionManager
|
ptyMgr *PTYSessionManager
|
||||||
sessionsMu sync.Mutex
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWSHandler(sessionMgr *ChatSessionManager) *WSHandler {
|
func NewTerminalHandler(ptyMgr *PTYSessionManager) *TerminalHandler {
|
||||||
return &WSHandler{
|
return &TerminalHandler{ptyMgr: ptyMgr}
|
||||||
sessionMgr: sessionMgr,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (wh *WSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *TerminalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
project := r.URL.Query().Get("project")
|
project := r.URL.Query().Get("project")
|
||||||
projectDir := r.URL.Query().Get("project_dir")
|
projectDir := r.URL.Query().Get("project_dir")
|
||||||
if project == "" || projectDir == "" {
|
if project == "" || projectDir == "" {
|
||||||
@ -50,73 +47,61 @@ func (wh *WSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
// Single write channel — all writes go through here to avoid concurrent writes
|
sess, isNew, err := h.ptyMgr.GetOrCreate(project, projectDir)
|
||||||
writeCh := make(chan string, 100)
|
if err != nil {
|
||||||
|
log.Printf("PTY session error: %v", err)
|
||||||
|
conn.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf("\r\nGreška: %v\r\n", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subID := fmt.Sprintf("ws-%d", time.Now().UnixNano())
|
||||||
|
outputCh := sess.Subscribe(subID)
|
||||||
|
defer sess.Unsubscribe(subID)
|
||||||
|
|
||||||
|
// Send buffered output for reconnect (replay)
|
||||||
|
if !isNew {
|
||||||
|
buffered := sess.GetBuffer()
|
||||||
|
if len(buffered) > 0 {
|
||||||
|
conn.WriteMessage(websocket.BinaryMessage, buffered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialized write channel to prevent concurrent WebSocket writes
|
||||||
|
writeCh := make(chan []byte, 256)
|
||||||
writeDone := make(chan struct{})
|
writeDone := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
defer close(writeDone)
|
defer close(writeDone)
|
||||||
for msg := range writeCh {
|
for data := range writeCh {
|
||||||
if err := conn.WriteMessage(websocket.TextMessage, []byte(msg)); err != nil {
|
if err := conn.WriteMessage(websocket.BinaryMessage, data); err != nil {
|
||||||
log.Printf("WebSocket write error: %v", err)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Helper to send via write channel
|
// PTY output → WebSocket (via write channel)
|
||||||
send := func(text string) {
|
|
||||||
select {
|
|
||||||
case writeCh <- text:
|
|
||||||
default:
|
|
||||||
log.Printf("Write channel full, dropping message")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sessionID := fmt.Sprintf("%s-%s", project, r.RemoteAddr)
|
|
||||||
subID := fmt.Sprintf("ws-%d", time.Now().UnixNano())
|
|
||||||
|
|
||||||
sess, isNew, err := wh.sessionMgr.GetOrCreate(sessionID, projectDir)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Session create error: %v", err)
|
|
||||||
send(FragmentSystemMessage(fmt.Sprintf("Greška pri pokretanju Claude-a: %v", err)))
|
|
||||||
close(writeCh)
|
|
||||||
<-writeDone
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send status
|
|
||||||
send(FragmentStatus(true))
|
|
||||||
|
|
||||||
if isNew {
|
|
||||||
send(FragmentSystemMessage("Claude sesija pokrenuta. Možeš da pišeš."))
|
|
||||||
} else {
|
|
||||||
// Replay buffer
|
|
||||||
buffer := sess.GetBuffer()
|
|
||||||
for _, msg := range buffer {
|
|
||||||
send(msg.Content)
|
|
||||||
}
|
|
||||||
send(FragmentSystemMessage("Ponovo povezan. Istorija učitana."))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to session broadcasts
|
|
||||||
sub := sess.Subscribe(subID)
|
|
||||||
defer sess.Unsubscribe(subID)
|
|
||||||
|
|
||||||
// Start event listener if new session
|
|
||||||
if isNew {
|
|
||||||
go wh.listenEvents(sess)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward broadcast messages to the write channel
|
|
||||||
go func() {
|
go func() {
|
||||||
for fragment := range sub.Ch {
|
for data := range outputCh {
|
||||||
send(fragment)
|
select {
|
||||||
|
case writeCh <- data:
|
||||||
|
default:
|
||||||
|
// Drop if write channel is full
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Read pump: messages from browser
|
// Watch for process exit
|
||||||
|
go func() {
|
||||||
|
<-sess.Done()
|
||||||
|
// Send exit message and close
|
||||||
|
select {
|
||||||
|
case writeCh <- []byte("\r\n\033[33m[Sesija završena]\033[0m\r\n"):
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// WebSocket → PTY (read pump)
|
||||||
for {
|
for {
|
||||||
_, raw, err := conn.ReadMessage()
|
_, msg, err := conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||||
log.Printf("WebSocket read error: %v", err)
|
log.Printf("WebSocket read error: %v", err)
|
||||||
@ -124,183 +109,22 @@ func (wh *WSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
var msg wsMessage
|
// Check for resize message
|
||||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
var resize resizeMsg
|
||||||
log.Printf("Invalid WS message: %v", err)
|
if json.Unmarshal(msg, &resize) == nil && resize.Type == "resize" && resize.Cols > 0 && resize.Rows > 0 {
|
||||||
|
if err := sess.Resize(resize.Rows, resize.Cols); err != nil {
|
||||||
|
log.Printf("PTY resize error: %v", err)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
text := strings.TrimSpace(msg.Message)
|
// Regular keyboard input → PTY
|
||||||
if text == "" {
|
if _, err := sess.WriteInput(msg); err != nil {
|
||||||
continue
|
log.Printf("PTY write error: %v", err)
|
||||||
}
|
break
|
||||||
|
|
||||||
isPlan := msg.Mode == "plan"
|
|
||||||
|
|
||||||
// Add user message to buffer (broadcasts to all subscribers including us)
|
|
||||||
userFragment := FragmentUserMessage(text, isPlan)
|
|
||||||
sess.AddMessage(ChatMessage{
|
|
||||||
Role: "user",
|
|
||||||
Content: userFragment,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Clear input and show typing
|
|
||||||
send(FragmentCombine(FragmentClearInput(), FragmentTypingIndicator(true)))
|
|
||||||
|
|
||||||
// In plan mode, wrap the message with planning instructions
|
|
||||||
cliText := text
|
|
||||||
if isPlan {
|
|
||||||
cliText = "[PLAN MODE] Only analyze, plan, and explain. Do NOT use tools to write, edit, or create files. Do NOT execute commands. Just provide your analysis and step-by-step plan.\n\n" + text
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send to claude CLI
|
|
||||||
if err := sess.Process.Send(cliText); err != nil {
|
|
||||||
log.Printf("Send to CLI error: %v", err)
|
|
||||||
send(FragmentSystemMessage("Greška pri slanju poruke"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
close(writeCh)
|
close(writeCh)
|
||||||
<-writeDone
|
<-writeDone
|
||||||
}
|
}
|
||||||
|
|
||||||
// listenEvents reads events from the CLI process and broadcasts via AddMessage.
|
|
||||||
func (wh *WSHandler) listenEvents(sess *ChatSession) {
|
|
||||||
var currentMsgID string
|
|
||||||
var currentText strings.Builder
|
|
||||||
msgCounter := 0
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case event, ok := <-sess.Process.Events:
|
|
||||||
if !ok {
|
|
||||||
sess.AddMessage(ChatMessage{
|
|
||||||
Role: "system",
|
|
||||||
Content: FragmentSystemMessage("Claude sesija završena."),
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch event.Type {
|
|
||||||
case "system":
|
|
||||||
if event.Subtype == "init" {
|
|
||||||
log.Printf("CLI session started: %s", event.SessionID)
|
|
||||||
}
|
|
||||||
|
|
||||||
case "stream_event":
|
|
||||||
if event.Event == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
wh.handleStreamEvent(sess, event.Event, ¤tMsgID, ¤tText, &msgCounter)
|
|
||||||
|
|
||||||
case "assistant":
|
|
||||||
if event.Message != nil {
|
|
||||||
for _, c := range event.Message.Content {
|
|
||||||
switch c.Type {
|
|
||||||
case "tool_use":
|
|
||||||
inputStr := ""
|
|
||||||
if c.Input != nil {
|
|
||||||
if b, err := json.Marshal(c.Input); err == nil {
|
|
||||||
inputStr = string(b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fragment := FragmentToolCall(c.Name, inputStr)
|
|
||||||
sess.AddMessage(ChatMessage{
|
|
||||||
Role: "tool",
|
|
||||||
Content: fragment,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
})
|
|
||||||
case "text":
|
|
||||||
if currentMsgID != "" && currentText.Len() > 0 {
|
|
||||||
rendered := renderMarkdown(currentText.String())
|
|
||||||
fragment := FragmentAssistantComplete(currentMsgID, rendered)
|
|
||||||
sess.AddMessage(ChatMessage{
|
|
||||||
Role: "assistant",
|
|
||||||
Content: fragment,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
})
|
|
||||||
currentText.Reset()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case "result":
|
|
||||||
fragment := FragmentTypingIndicator(false)
|
|
||||||
sess.AddMessage(ChatMessage{
|
|
||||||
Role: "system",
|
|
||||||
Content: fragment,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
})
|
|
||||||
if event.Result != nil {
|
|
||||||
parts := []string{}
|
|
||||||
if event.Result.Duration > 0 {
|
|
||||||
secs := event.Result.Duration / 1000
|
|
||||||
if secs >= 60 {
|
|
||||||
parts = append(parts, fmt.Sprintf("%.0fm %.0fs", secs/60, float64(int(secs)%60)))
|
|
||||||
} else {
|
|
||||||
parts = append(parts, fmt.Sprintf("%.1fs", secs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if event.Result.CostUSD > 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("$%.4f", event.Result.CostUSD))
|
|
||||||
}
|
|
||||||
if event.Result.NumTurns > 0 {
|
|
||||||
parts = append(parts, fmt.Sprintf("%d turn(s)", event.Result.NumTurns))
|
|
||||||
}
|
|
||||||
if len(parts) > 0 {
|
|
||||||
costMsg := FragmentSystemMessage(strings.Join(parts, " · "))
|
|
||||||
sess.AddMessage(ChatMessage{
|
|
||||||
Role: "system",
|
|
||||||
Content: costMsg,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case err, ok := <-sess.Process.Errors:
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("CLI error [%s]: %v", sess.ID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (wh *WSHandler) handleStreamEvent(sess *ChatSession, se *StreamEvent, currentMsgID *string, currentText *strings.Builder, msgCounter *int) {
|
|
||||||
switch se.Type {
|
|
||||||
case "content_block_start":
|
|
||||||
// Only create a message div for text blocks, skip tool_use blocks
|
|
||||||
if se.ContentBlock != nil && se.ContentBlock.Type != "text" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
*msgCounter++
|
|
||||||
*currentMsgID = fmt.Sprintf("msg-%d-%d", time.Now().UnixMilli(), *msgCounter)
|
|
||||||
currentText.Reset()
|
|
||||||
fragment := FragmentAssistantStart(*currentMsgID)
|
|
||||||
sess.AddMessage(ChatMessage{
|
|
||||||
Role: "assistant",
|
|
||||||
Content: fragment,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
})
|
|
||||||
|
|
||||||
case "content_block_delta":
|
|
||||||
if se.Delta != nil && se.Delta.Text != "" {
|
|
||||||
currentText.WriteString(se.Delta.Text)
|
|
||||||
fragment := FragmentAssistantChunk(*currentMsgID, se.Delta.Text)
|
|
||||||
sess.AddMessage(ChatMessage{
|
|
||||||
Role: "assistant",
|
|
||||||
Content: fragment,
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderMarkdown(text string) string {
|
|
||||||
return RenderMD(text)
|
|
||||||
}
|
|
||||||
|
|||||||
42
ws_test.go
42
ws_test.go
@ -1,29 +1,33 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRenderMarkdown(t *testing.T) {
|
func TestResizeMsgParsing(t *testing.T) {
|
||||||
tests := []struct {
|
msg := `{"type":"resize","cols":120,"rows":40}`
|
||||||
name string
|
var r resizeMsg
|
||||||
input string
|
if err := json.Unmarshal([]byte(msg), &r); err != nil {
|
||||||
contains string
|
t.Fatal(err)
|
||||||
}{
|
|
||||||
{"plain text", "Hello world", "<p>Hello world</p>"},
|
|
||||||
{"bold", "**bold**", "<strong>bold</strong>"},
|
|
||||||
{"code block", "```\ncode\n```", "<code>code"},
|
|
||||||
{"inline code", "`inline`", "<code>inline</code>"},
|
|
||||||
{"heading", "# Title", "<h1>Title</h1>"},
|
|
||||||
}
|
}
|
||||||
|
if r.Type != "resize" {
|
||||||
for _, tt := range tests {
|
t.Errorf("got type %q", r.Type)
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
result := renderMarkdown(tt.input)
|
|
||||||
if !strings.Contains(result, tt.contains) {
|
|
||||||
t.Errorf("renderMarkdown(%q) = %q, want to contain %q", tt.input, result, tt.contains)
|
|
||||||
}
|
}
|
||||||
})
|
if r.Cols != 120 {
|
||||||
|
t.Errorf("got cols %d", r.Cols)
|
||||||
|
}
|
||||||
|
if r.Rows != 40 {
|
||||||
|
t.Errorf("got rows %d", r.Rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResizeMsgNotInput(t *testing.T) {
|
||||||
|
// Regular keyboard input should NOT parse as resize
|
||||||
|
msg := []byte("hello")
|
||||||
|
var r resizeMsg
|
||||||
|
err := json.Unmarshal(msg, &r)
|
||||||
|
if err == nil && r.Type == "resize" {
|
||||||
|
t.Error("regular input should not parse as resize")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user