Zamena chat UI sa pravim terminalom (xterm.js + PTY)
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:
djuka 2026-02-18 05:54:40 +00:00
parent 93dbb33198
commit adea7ca28d
9 changed files with 708 additions and 395 deletions

4
go.mod
View File

@ -1,8 +1,10 @@
module claude-web-chat
go 1.23.6
go 1.24.0
require (
github.com/creack/pty v1.1.24 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/yuin/goldmark v1.7.8 // indirect
golang.org/x/crypto v0.48.0 // indirect
)

4
go.sum
View File

@ -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/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
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
View File

@ -12,7 +12,7 @@ var (
cfg *Config
templates *TemplateRenderer
sessionMgr *SessionManager
chatMgr *ChatSessionManager
ptyMgr *PTYSessionManager
)
func main() {
@ -29,10 +29,10 @@ func main() {
}
sessionMgr = NewSessionManager(cfg.SessionSecret)
chatMgr = NewChatSessionManager()
defer chatMgr.Stop()
ptyMgr = NewPTYSessionManager()
defer ptyMgr.Stop()
wsHandler := NewWSHandler(chatMgr)
wsHandler := NewTerminalHandler(ptyMgr)
mux := http.NewServeMux()
@ -49,6 +49,8 @@ func main() {
mux.Handle("GET /chat/{project}", AuthMiddleware(sessionMgr, http.HandlerFunc(handleChat)))
mux.Handle("GET /ws", AuthMiddleware(sessionMgr, wsHandler))
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)
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")
password := r.FormValue("password")
if username != cfg.Username || password != cfg.Password {
if username != cfg.Username || !cfg.CheckPassword(password) {
w.WriteHeader(http.StatusUnauthorized)
templates.Render(w, "login.html", map[string]string{"Error": "Pogrešno korisničko ime ili lozinka"})
return
@ -118,20 +120,57 @@ func handleChat(w http.ResponseWriter, r *http.Request) {
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{
"Project": project,
"ProjectDir": projectDir,
"Files": files,
}
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) {
project := r.URL.Query().Get("project")
relPath := r.URL.Query().Get("path")

30
markdown_test.go Normal file
View 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
View 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
View 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")
}
})
}

View File

@ -3,146 +3,170 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Claude Web Chat — {{.Project}}</title>
<link rel="stylesheet" href="/static/style.css">
<script src="/static/htmx.min.js"></script>
<script src="/static/ws.js"></script>
<title>Claude — {{.Project}}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
<style>
* { 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>
<body>
<div class="chat-container">
<!-- Sidebar -->
<div class="sidebar">
<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>
<div class="terminal-header">
<span class="title">claude — {{.Project}}</span>
<div class="controls">
<span id="ws-status" class="status">Povezivanje...</span>
<a href="/projects">← Projekti</a>
</div>
</div>
<div id="terminal-container"></div>
<div class="chat-messages" id="chat-messages">
<!-- Messages will be appended here via OOB swap -->
</div>
<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 src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
<script>
// Mode switching
let currentMode = 'code';
const statusEl = document.getElementById('ws-status');
const container = document.getElementById('terminal-container');
function setMode(mode) {
currentMode = mode;
document.getElementById('mode-input').value = mode;
const codeEl = document.getElementById('mode-code');
const planEl = document.getElementById('mode-plan');
const textarea = document.getElementById('message-input');
if (mode === 'plan') {
codeEl.classList.remove('mode-active');
planEl.classList.add('mode-active');
textarea.placeholder = 'Plan mod — opiši šta treba analizirati...';
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace",
theme: {
background: '#0d1117',
foreground: '#e0e0e0',
cursor: '#e94560',
cursorAccent: '#0d1117',
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 {
planEl.classList.remove('mode-active');
codeEl.classList.add('mode-active');
textarea.placeholder = 'Pošalji poruku...';
term.write(event.data);
}
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
const textarea = document.getElementById('message-input');
if (textarea) {
textarea.addEventListener('input', function() {
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();
// Send keyboard input to server
term.onData(function(data) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
// Auto-scroll chat
const chatMessages = document.getElementById('chat-messages');
const observer = new MutationObserver(function() {
chatMessages.scrollTop = chatMessages.scrollHeight;
// Send terminal resize to server
term.onResize(function(size) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({type: 'resize', cols: size.cols, rows: size.rows}));
}
});
observer.observe(chatMessages, { childList: true, subtree: true });
// File viewer
function loadFile(relPath) {
fetch('/api/file?project={{.Project}}&path=' + encodeURIComponent(relPath))
.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));
}
// Fit terminal on window resize
window.addEventListener('resize', function() {
fitAddon.fit();
});
function closeFileViewer() {
document.getElementById('file-viewer').classList.add('hidden');
}
// Start connection
connect();
</script>
</body>
</html>

304
ws.go
View File

@ -5,37 +5,34 @@ import (
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool { return true },
}
type wsMessage struct {
Message string `json:"message"`
Mode string `json:"mode"` // "code" or "plan"
// resizeMsg is sent from the browser when the terminal size changes.
type resizeMsg struct {
Type string `json:"type"`
Cols uint16 `json:"cols"`
Rows uint16 `json:"rows"`
}
// WSHandler handles WebSocket connections for chat.
type WSHandler struct {
sessionMgr *ChatSessionManager
sessionsMu sync.Mutex
// TerminalHandler handles WebSocket connections for terminal sessions.
type TerminalHandler struct {
ptyMgr *PTYSessionManager
}
func NewWSHandler(sessionMgr *ChatSessionManager) *WSHandler {
return &WSHandler{
sessionMgr: sessionMgr,
}
func NewTerminalHandler(ptyMgr *PTYSessionManager) *TerminalHandler {
return &TerminalHandler{ptyMgr: ptyMgr}
}
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")
projectDir := r.URL.Query().Get("project_dir")
if project == "" || projectDir == "" {
@ -50,73 +47,61 @@ func (wh *WSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
defer conn.Close()
// Single write channel — all writes go through here to avoid concurrent writes
writeCh := make(chan string, 100)
sess, isNew, err := h.ptyMgr.GetOrCreate(project, projectDir)
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{})
go func() {
defer close(writeDone)
for msg := range writeCh {
if err := conn.WriteMessage(websocket.TextMessage, []byte(msg)); err != nil {
log.Printf("WebSocket write error: %v", err)
for data := range writeCh {
if err := conn.WriteMessage(websocket.BinaryMessage, data); err != nil {
return
}
}
}()
// Helper to send 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
// PTY output → WebSocket (via write channel)
go func() {
for fragment := range sub.Ch {
send(fragment)
for data := range outputCh {
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 {
_, raw, err := conn.ReadMessage()
_, msg, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
log.Printf("WebSocket read error: %v", err)
@ -124,183 +109,22 @@ func (wh *WSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
break
}
var msg wsMessage
if err := json.Unmarshal(raw, &msg); err != nil {
log.Printf("Invalid WS message: %v", err)
// Check for resize message
var resize resizeMsg
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
}
text := strings.TrimSpace(msg.Message)
if text == "" {
continue
}
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"))
// Regular keyboard input → PTY
if _, err := sess.WriteInput(msg); err != nil {
log.Printf("PTY write error: %v", err)
break
}
}
// Cleanup
close(writeCh)
<-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, &currentMsgID, &currentText, &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)
}

View File

@ -1,29 +1,33 @@
package main
import (
"strings"
"encoding/json"
"testing"
)
func TestRenderMarkdown(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>"},
func TestResizeMsgParsing(t *testing.T) {
msg := `{"type":"resize","cols":120,"rows":40}`
var r resizeMsg
if err := json.Unmarshal([]byte(msg), &r); err != nil {
t.Fatal(err)
}
if r.Type != "resize" {
t.Errorf("got type %q", r.Type)
}
if r.Cols != 120 {
t.Errorf("got cols %d", r.Cols)
}
if r.Rows != 40 {
t.Errorf("got rows %d", r.Rows)
}
}
for _, tt := range tests {
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)
}
})
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")
}
}