From c970cb24194f480fa8770f7d070d1022793dc030 Mon Sep 17 00:00:00 2001 From: djuka Date: Fri, 20 Feb 2026 15:31:27 +0000 Subject: [PATCH] Konzola: xterm.js + WebSocket + PTY real-time terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nova pty_session.go: RingBuffer, consolePTYSession, spawnConsolePTY - Nova ws.go: WebSocket handler za PTY bidirekcioni I/O - console.go: koristi consolePTYSession umesto starih pipe-ova - console.html: xterm.js 5.5.0 CDN, FitAddon, WebLinksAddon - Podrška za resize, binarni podaci, replay buffer (1MB) - 8 novih testova (RingBuffer + xterm konzola) — ukupno 179 Co-Authored-By: Claude Opus 4.6 --- code/go.mod | 1 + code/go.sum | 2 + code/internal/server/console.go | 40 ++- code/internal/server/pty_session.go | 215 ++++++++++++++ code/internal/server/server.go | 1 + code/internal/server/server_test.go | 123 ++++++++ code/internal/server/ws.go | 146 ++++++++++ code/web/static/style.css | 424 ++++++++++++++++------------ code/web/templates/console.html | 218 +++++++++++--- 9 files changed, 935 insertions(+), 235 deletions(-) create mode 100644 code/internal/server/pty_session.go create mode 100644 code/internal/server/ws.go diff --git a/code/go.mod b/code/go.mod index e5d5cdf..3d6db69 100644 --- a/code/go.mod +++ b/code/go.mod @@ -16,6 +16,7 @@ require ( github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-yaml v1.18.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect diff --git a/code/go.sum b/code/go.sum index c07be0f..cdb620a 100644 --- a/code/go.sum +++ b/code/go.sum @@ -30,6 +30,8 @@ github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= diff --git a/code/internal/server/console.go b/code/internal/server/console.go index 442133a..e0cc310 100644 --- a/code/internal/server/console.go +++ b/code/internal/server/console.go @@ -19,6 +19,7 @@ type sessionState struct { mu sync.Mutex status string // "idle" or "running" cmd *exec.Cmd + ptySess *consolePTYSession execID string taskID string // which task is being worked on (if any) history []historyEntry @@ -152,37 +153,31 @@ func cleanEnv() []string { return env } -// runCommand executes a command in a PTY and streams output to listeners. +// runCommand spawns a PTY-backed claude CLI process and monitors it. func (s *Server) runCommand(session *sessionState, command, execID string) { - cmd := exec.Command("claude", "--permission-mode", "dontAsk", "-p", command) - cmd.Dir = s.projectRoot() - cmd.Env = cleanEnv() - - ptmx, err := startPTY(cmd) + ptySess, err := spawnConsolePTY(s.projectRoot(), command) if err != nil { - s.sendToSession(session, "[greška pri pokretanju: "+err.Error()+"]") s.finishSession(session, execID, "error") return } - defer ptmx.Close() session.mu.Lock() - session.cmd = cmd + session.cmd = ptySess.Cmd + session.ptySess = ptySess session.mu.Unlock() - // Read PTY output and send to session - readPTY(ptmx, func(line string) { - s.sendToSession(session, line) - }) + // Wait for process to exit + <-ptySess.Done() - err = cmd.Wait() status := "done" - if err != nil { - if _, ok := err.(*exec.ExitError); ok { - status = "error" - } + if ptySess.Cmd.ProcessState != nil && !ptySess.Cmd.ProcessState.Success() { + status = "error" } + session.mu.Lock() + session.ptySess = nil + session.mu.Unlock() + s.finishSession(session, execID, status) } @@ -197,7 +192,6 @@ func (s *Server) sendToSession(session *sessionState, line string) { select { case ch <- line: default: - // Skip if channel is full } } } @@ -313,12 +307,16 @@ func (s *Server) handleConsoleKill(c *gin.Context) { session.mu.Lock() defer session.mu.Unlock() - if session.status != "running" || session.cmd == nil { + if session.status != "running" { c.JSON(http.StatusOK, gin.H{"status": "idle", "message": "sesija nije aktivna"}) return } - if session.cmd.Process != nil { + // Close PTY session if it exists + if session.ptySess != nil { + session.ptySess.Close() + session.ptySess = nil + } else if session.cmd != nil && session.cmd.Process != nil { session.cmd.Process.Kill() } diff --git a/code/internal/server/pty_session.go b/code/internal/server/pty_session.go new file mode 100644 index 0000000..20399aa --- /dev/null +++ b/code/internal/server/pty_session.go @@ -0,0 +1,215 @@ +package server + +import ( + "fmt" + "os" + "os/exec" + "strings" + "sync" + "time" + + "github.com/creack/pty" +) + +const ( + outputBufferSize = 1024 * 1024 // 1MB ring buffer for replay +) + +// RingBuffer is a fixed-size circular buffer for terminal output. +type RingBuffer struct { + data []byte + size int + pos int + full bool + mu sync.Mutex +} + +// NewRingBuffer creates a new ring buffer with the given size. +func NewRingBuffer(size int) *RingBuffer { + return &RingBuffer{data: make([]byte, size), size: size} +} + +// Write appends data to the ring buffer. +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 + } + } +} + +// Bytes returns the buffer contents in correct order. +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 +} + +// Reset clears the buffer. +func (rb *RingBuffer) Reset() { + rb.mu.Lock() + defer rb.mu.Unlock() + rb.pos = 0 + rb.full = false +} + +// consolePTYSession manages a single claude CLI running in a pseudo-terminal. +type consolePTYSession struct { + ID string + Ptmx *os.File + Cmd *exec.Cmd + buffer *RingBuffer + subscribers map[string]chan []byte + mu sync.Mutex + done chan struct{} + lastActive time.Time +} + +// spawnConsolePTY starts a new claude CLI in a PTY for the console. +func spawnConsolePTY(projectDir, prompt string) (*consolePTYSession, error) { + cmd := exec.Command("claude", "--permission-mode", "dontAsk", "-p", prompt) + cmd.Dir = projectDir + cmd.Env = cleanEnvForPTY() + + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 24, Cols: 120}) + if err != nil { + return nil, fmt.Errorf("start pty: %w", err) + } + + sess := &consolePTYSession{ + ID: fmt.Sprintf("pty-%d", time.Now().UnixNano()), + 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 *consolePTYSession) 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 _, ch := range s.subscribers { + select { + case ch <- data: + default: + } + } + s.mu.Unlock() + } +} + +// waitExit waits for the CLI process to exit and signals done. +func (s *consolePTYSession) waitExit() { + if s.Cmd.Process != nil { + s.Cmd.Wait() + } + close(s.done) +} + +// Subscribe adds a subscriber for PTY output. +func (s *consolePTYSession) 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 *consolePTYSession) 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 *consolePTYSession) 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 *consolePTYSession) 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 *consolePTYSession) GetBuffer() []byte { + return s.buffer.Bytes() +} + +// Done returns a channel that closes when the process exits. +func (s *consolePTYSession) Done() <-chan struct{} { + return s.done +} + +// Close terminates the PTY session. +func (s *consolePTYSession) 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() + } +} + +// cleanEnvForPTY returns environment with proper terminal settings. +func cleanEnvForPTY() []string { + var env []string + for _, e := range os.Environ() { + if strings.HasPrefix(e, "CLAUDECODE=") || + strings.HasPrefix(e, "CLAUDE_CODE_ENTRYPOINT=") || + strings.HasPrefix(e, "TERM=") || + strings.HasPrefix(e, "COLORTERM=") { + continue + } + env = append(env, e) + } + env = append(env, "TERM=xterm-256color", "COLORTERM=truecolor") + return env +} diff --git a/code/internal/server/server.go b/code/internal/server/server.go index 6c0efcd..65cf8bf 100644 --- a/code/internal/server/server.go +++ b/code/internal/server/server.go @@ -121,6 +121,7 @@ func (s *Server) setupRoutes() { s.Router.POST("/console/kill/:session", s.handleConsoleKill) s.Router.GET("/console/sessions", s.handleConsoleSessions) s.Router.GET("/console/history/:session", s.handleConsoleHistory) + s.Router.GET("/console/ws/:session", s.handleConsoleWS) // Docs routes s.Router.GET("/docs", s.handleDocsList) diff --git a/code/internal/server/server_test.go b/code/internal/server/server_test.go index 2c93a89..1027e24 100644 --- a/code/internal/server/server_test.go +++ b/code/internal/server/server_test.go @@ -2008,3 +2008,126 @@ func findStr(s, substr string) bool { } return false } + +// ── RingBuffer tests ──────────────────────────────── + +func TestRingBuffer_WriteAndRead(t *testing.T) { + rb := NewRingBuffer(16) + rb.Write([]byte("hello")) + + got := rb.Bytes() + if string(got) != "hello" { + t.Errorf("expected 'hello', got '%s'", got) + } +} + +func TestRingBuffer_Overflow(t *testing.T) { + rb := NewRingBuffer(8) + rb.Write([]byte("abcdefgh")) // exactly fills + rb.Write([]byte("ij")) // wraps around + + got := rb.Bytes() + // Should contain the last 8 bytes: "cdefghij" + if string(got) != "cdefghij" { + t.Errorf("expected 'cdefghij', got '%s'", got) + } +} + +func TestRingBuffer_Reset(t *testing.T) { + rb := NewRingBuffer(16) + rb.Write([]byte("test")) + rb.Reset() + + got := rb.Bytes() + if len(got) != 0 { + t.Errorf("expected empty after reset, got %d bytes", len(got)) + } +} + +// ── xterm.js console page tests ───────────────────── + +func TestConsolePage_HasXtermJS(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/console", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "xterm.min.js") { + t.Error("expected xterm.min.js CDN link in console page") + } + if !containsStr(body, "addon-fit") { + t.Error("expected addon-fit CDN link in console page") + } + if !containsStr(body, "xterm.css") { + t.Error("expected xterm.css CDN link in console page") + } +} + +func TestConsolePage_HasWebSocket(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/console", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "/console/ws/") { + t.Error("expected WebSocket URL /console/ws/ in console page") + } + if !containsStr(body, "new WebSocket") { + t.Error("expected WebSocket constructor in console page") + } +} + +func TestConsolePage_HasTerminalContainers(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/console", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, `id="terminal-1"`) { + t.Error("expected terminal-1 container") + } + if !containsStr(body, `id="terminal-2"`) { + t.Error("expected terminal-2 container") + } + if !containsStr(body, "console-terminal") { + t.Error("expected console-terminal class") + } +} + +func TestConsolePage_HasBinaryMessageSupport(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/console", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "arraybuffer") { + t.Error("expected arraybuffer binary type for WebSocket") + } + if !containsStr(body, "Uint8Array") { + t.Error("expected Uint8Array handling for binary messages") + } +} + +func TestConsolePage_HasResizeHandler(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/console", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + body := w.Body.String() + if !containsStr(body, "fitAddon.fit()") { + t.Error("expected fitAddon.fit() for resize handling") + } + if !containsStr(body, `'resize'`) { + t.Error("expected resize message type in WebSocket handler") + } +} diff --git a/code/internal/server/ws.go b/code/internal/server/ws.go new file mode 100644 index 0000000..7e0b2fc --- /dev/null +++ b/code/internal/server/ws.go @@ -0,0 +1,146 @@ +package server + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" +) + +var wsUpgrader = websocket.Upgrader{ + ReadBufferSize: 4096, + WriteBufferSize: 4096, + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// wsResizeMsg is sent from the browser when the terminal size changes. +type wsResizeMsg struct { + Type string `json:"type"` + Cols uint16 `json:"cols"` + Rows uint16 `json:"rows"` +} + +// handleConsoleWS handles WebSocket connections for console PTY sessions. +func (s *Server) handleConsoleWS(c *gin.Context) { + sessionNum := c.Param("session") + if sessionNum != "1" && sessionNum != "2" { + c.JSON(http.StatusBadRequest, gin.H{"error": "sesija mora biti 1 ili 2"}) + return + } + + conn, err := wsUpgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + log.Printf("WebSocket upgrade error: %v", err) + return + } + defer conn.Close() + + idx := 0 + if sessionNum == "2" { + idx = 1 + } + + session := s.console.getSession(idx) + + // Wait for PTY session to be available (it gets set when a command is executed) + session.mu.Lock() + ptySess := session.ptySess + session.mu.Unlock() + + if ptySess == nil { + // No active PTY — send message and wait + conn.WriteMessage(websocket.TextMessage, []byte("\r\n\033[33m[Nema aktivne sesije. Pokrenite komandu.]\033[0m\r\n")) + + // Poll for session to start + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ticker.C: + session.mu.Lock() + ptySess = session.ptySess + session.mu.Unlock() + if ptySess != nil { + goto connected + } + case <-c.Request.Context().Done(): + return + } + } + } + +connected: + subID := fmt.Sprintf("ws-%d", time.Now().UnixNano()) + outputCh := ptySess.Subscribe(subID) + defer ptySess.Unsubscribe(subID) + + // Send buffered output for reconnect + buffered := ptySess.GetBuffer() + if len(buffered) > 0 { + conn.WriteMessage(websocket.BinaryMessage, buffered) + } + + // Serialized write channel + writeCh := make(chan []byte, 256) + writeDone := make(chan struct{}) + go func() { + defer close(writeDone) + for data := range writeCh { + if err := conn.WriteMessage(websocket.BinaryMessage, data); err != nil { + return + } + } + }() + + // PTY output → WebSocket + go func() { + for data := range outputCh { + select { + case writeCh <- data: + default: + } + } + }() + + // Watch for process exit + go func() { + <-ptySess.Done() + select { + case writeCh <- []byte("\r\n\033[33m[Sesija završena]\033[0m\r\n"): + default: + } + }() + + // WebSocket → PTY (read pump) + for { + _, msg, err := conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { + log.Printf("WebSocket read error: %v", err) + } + break + } + + // Check for resize message + var resize wsResizeMsg + if json.Unmarshal(msg, &resize) == nil && resize.Type == "resize" && resize.Cols > 0 && resize.Rows > 0 { + if err := ptySess.Resize(resize.Rows, resize.Cols); err != nil { + log.Printf("PTY resize error: %v", err) + } + continue + } + + // Regular keyboard input → PTY + if _, err := ptySess.WriteInput(msg); err != nil { + log.Printf("PTY write error: %v", err) + break + } + } + + close(writeCh) + <-writeDone +} diff --git a/code/web/static/style.css b/code/web/static/style.css index b9f83b1..1a5ad53 100644 --- a/code/web/static/style.css +++ b/code/web/static/style.css @@ -1,23 +1,115 @@ +/* === CSS varijable — teme === */ +:root, +[data-theme="dark"] { + --bg-page: #1a1a2e; + --bg-panel: #16213e; + --bg-deep: #111; + --bg-hover: #0f3460; + --border: #0f3460; + --border-light: #333; + --border-disabled: #444; + --accent: #e94560; + --success: #4ecca3; + --info: #6ec6ff; + --warning: #ffd93d; + --text: #eee; + --text-light: #ddd; + --text-secondary: #aaa; + --text-muted: #888; + --text-dim: #666; + --text-disabled: #555; + --text-on-color: #1a1a2e; + --overlay: rgba(0,0,0,0.6); + --shadow: rgba(0,0,0,0.4); + --drag-over: rgba(15, 52, 96, 0.3); + --accent-shadow: rgba(233, 69, 96, 0.3); +} + +[data-theme="light"] { + --bg-page: #f0f2f5; + --bg-panel: #ffffff; + --bg-deep: #f5f6fa; + --bg-hover: #e0e5ed; + --border: #c9d1db; + --border-light: #d8dee6; + --border-disabled: #ccc; + --accent: #d63851; + --success: #1a8a6a; + --info: #2b7dbd; + --warning: #b8860b; + --text: #1e293b; + --text-light: #334155; + --text-secondary: #475569; + --text-muted: #64748b; + --text-dim: #94a3b8; + --text-disabled: #a0aec0; + --text-on-color: #ffffff; + --overlay: rgba(0,0,0,0.35); + --shadow: rgba(0,0,0,0.12); + --drag-over: rgba(59, 130, 246, 0.1); + --accent-shadow: rgba(214, 56, 81, 0.15); +} + +/* === Reset === */ * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: #1a1a2e; - color: #eee; + background: var(--bg-page); + color: var(--text); } +/* === Header === */ .header { padding: 16px 24px; - background: #16213e; + background: var(--bg-panel); display: flex; justify-content: space-between; align-items: center; - border-bottom: 2px solid #0f3460; + border-bottom: 2px solid var(--border); } .header h1 { font-size: 1.4em; } -.header .version { color: #888; font-size: 0.9em; } +.header .version { color: var(--text-muted); font-size: 0.9em; } +.header-right { + display: flex; + gap: 12px; + align-items: center; +} + +/* === Theme toggle === */ +.theme-toggle { + display: flex; + gap: 2px; + background: var(--bg-page); + border-radius: 6px; + padding: 2px; + border: 1px solid var(--border-light); +} + +.theme-btn { + padding: 4px 8px; + border: none; + background: transparent; + color: var(--text-muted); + cursor: pointer; + border-radius: 4px; + font-size: 0.8em; + transition: background 0.2s, color 0.2s; + line-height: 1; +} + +.theme-btn:hover { + color: var(--text); +} + +.theme-btn.active { + background: var(--bg-hover); + color: var(--text); +} + +/* === Board (Kanban) === */ .board { display: grid; grid-template-columns: repeat(5, 1fr); @@ -27,7 +119,7 @@ body { } .column { - background: #16213e; + background: var(--bg-panel); border-radius: 8px; padding: 12px; min-height: 200px; @@ -37,21 +129,22 @@ body { font-weight: bold; padding: 8px; margin-bottom: 8px; - border-bottom: 2px solid #0f3460; + border-bottom: 2px solid var(--border); display: flex; justify-content: space-between; } .column-count { - background: #0f3460; + background: var(--bg-hover); border-radius: 12px; padding: 2px 8px; font-size: 0.85em; } +/* === Task cards === */ .task-card { - background: #1a1a2e; - border: 1px solid #333; + background: var(--bg-page); + border: 1px solid var(--border-light); border-radius: 6px; padding: 10px; margin-bottom: 8px; @@ -60,13 +153,13 @@ body { } .task-card:hover { - border-color: #e94560; + border-color: var(--accent); transform: translateY(-1px); } .task-id { font-weight: bold; - color: #e94560; + color: var(--accent); } .task-title { @@ -77,16 +170,16 @@ body { .task-meta { margin-top: 6px; font-size: 0.75em; - color: #888; + color: var(--text-muted); } .task-deps { font-size: 0.75em; - color: #666; + color: var(--text-dim); margin-top: 4px; } -/* Task detail overlay (modal) */ +/* === Task detail modal === */ #task-detail { display: none; position: fixed; @@ -95,7 +188,7 @@ body { width: 100%; height: 100vh; z-index: 50; - background: rgba(0, 0, 0, 0.6); + background: var(--overlay); justify-content: center; align-items: center; } @@ -105,8 +198,8 @@ body { } .detail-inner { - background: #16213e; - border: 1px solid #0f3460; + background: var(--bg-panel); + border: 1px solid var(--border); border-radius: 10px; padding: 24px; width: 700px; @@ -122,14 +215,14 @@ body { top: 12px; right: 16px; font-size: 1.4em; - color: #888; + color: var(--text-muted); padding: 4px 8px; line-height: 1; } -.detail-close:hover { color: #e94560; } +.detail-close:hover { color: var(--accent); } -.detail-meta { margin-top: 12px; font-size: 0.9em; color: #aaa; } +.detail-meta { margin-top: 12px; font-size: 0.9em; color: var(--text-secondary); } .detail-meta p { margin-bottom: 4px; } .detail-actions { @@ -139,23 +232,33 @@ body { flex-wrap: wrap; } +.detail-content { + margin-top: 16px; + padding: 12px; + background: var(--bg-deep); + border-radius: 6px; + max-height: 60vh; + overflow-y: auto; +} + +/* === Buttons === */ .btn { display: inline-block; padding: 8px 16px; border-radius: 6px; - border: 1px solid #333; - background: #1a1a2e; - color: #eee; + border: 1px solid var(--border-light); + background: var(--bg-page); + color: var(--text); cursor: pointer; font-size: 0.85em; text-decoration: none; transition: background 0.2s; } -.btn:hover { background: #0f3460; } +.btn:hover { background: var(--bg-hover); } + +.btn-move { border-color: var(--accent); } -.btn-move { border-color: #e94560; } -/* Task action buttons */ .task-action { margin-top: 6px; text-align: right; @@ -166,27 +269,27 @@ body { padding: 4px 10px; } -.btn-run { border-color: #4ecca3; color: #4ecca3; } -.btn-run:hover { background: #4ecca3; color: #1a1a2e; } +.btn-run { border-color: var(--success); color: var(--success); } +.btn-run:hover { background: var(--success); color: var(--text-on-color); } -.btn-review { border-color: #6ec6ff; color: #6ec6ff; } -.btn-review:hover { background: #6ec6ff; color: #1a1a2e; } +.btn-review { border-color: var(--info); color: var(--info); } +.btn-review:hover { background: var(--info); color: var(--text-on-color); } -.btn-approve { border-color: #ffd93d; color: #ffd93d; } -.btn-approve:hover { background: #ffd93d; color: #1a1a2e; } +.btn-approve { border-color: var(--warning); color: var(--warning); } +.btn-approve:hover { background: var(--warning); color: var(--text-on-color); } -.btn-report { border-color: #888; color: #888; } -.btn-report:hover { background: #888; color: #1a1a2e; } +.btn-report { border-color: var(--text-muted); color: var(--text-muted); } +.btn-report:hover { background: var(--text-muted); color: var(--text-on-color); } .btn-blocked { - border-color: #444; - color: #555; + border-color: var(--border-disabled); + color: var(--text-disabled); cursor: default; } .btn-running { - border-color: #e94560; - color: #e94560; + border-color: var(--accent); + color: var(--accent); cursor: default; animation: pulse 1.5s ease infinite; } @@ -195,32 +298,28 @@ body { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } -.btn-success { border-color: #4ecca3; color: #4ecca3; } -.btn-success:hover { background: #4ecca3; color: #1a1a2e; } -.detail-content { - margin-top: 16px; - padding: 12px; - background: #111; - border-radius: 6px; - max-height: 60vh; - overflow-y: auto; +.btn-success { border-color: var(--success); color: var(--success); } +.btn-success:hover { background: var(--success); color: var(--text-on-color); } + +.btn-active { + background: var(--bg-hover); + border-color: var(--accent); } -/* Sortable column-tasks container */ +/* === Sortable / Drag & Drop === */ .column-tasks { min-height: 50px; } -/* Drag & Drop styles */ .task-ghost { opacity: 0.4; - border: 2px dashed #e94560; - background: #0f3460; + border: 2px dashed var(--accent); + background: var(--bg-hover); } .task-chosen { - box-shadow: 0 4px 16px rgba(233, 69, 96, 0.3); + box-shadow: 0 4px 16px var(--accent-shadow); } .task-drag { @@ -228,27 +327,26 @@ body { transform: rotate(2deg); } -/* Drop zone highlight */ .column-tasks.sortable-drag-over { - background: rgba(15, 52, 96, 0.3); + background: var(--drag-over); border-radius: 6px; } -/* Flash animations */ +/* === Flash animations === */ @keyframes flash-success { - 0% { background: #4ecca3; } - 100% { background: #1a1a2e; } + 0% { background: var(--success); } + 100% { background: var(--bg-page); } } @keyframes flash-error { - 0% { background: #e94560; } - 100% { background: #1a1a2e; } + 0% { background: var(--accent); } + 100% { background: var(--bg-page); } } .flash-success { animation: flash-success 0.5s ease; } .flash-error { animation: flash-error 0.5s ease; } -/* Toast notifications */ +/* === Toast === */ .toast { position: fixed; bottom: 20px; @@ -267,32 +365,25 @@ body { } .toast-success { - background: #4ecca3; - color: #1a1a2e; + background: var(--success); + color: var(--text-on-color); } .toast-error { - background: #e94560; + background: var(--accent); color: #fff; } -/* Header right section */ -.header-right { - display: flex; - gap: 12px; - align-items: center; -} - -/* Search */ +/* === Search === */ .search-wrapper { position: relative; } .search-wrapper input { - background: #1a1a2e; - border: 1px solid #333; + background: var(--bg-page); + border: 1px solid var(--border-light); border-radius: 6px; - color: #eee; + color: var(--text); padding: 6px 12px; font-size: 0.85em; width: 220px; @@ -301,7 +392,7 @@ body { } .search-wrapper input:focus { - border-color: #e94560; + border-color: var(--accent); width: 300px; } @@ -312,9 +403,9 @@ body { width: 400px; max-height: 500px; overflow-y: auto; - background: #16213e; + background: var(--bg-panel); border-radius: 8px; - box-shadow: 0 8px 24px rgba(0,0,0,0.4); + box-shadow: 0 8px 24px var(--shadow); z-index: 50; margin-top: 4px; } @@ -327,8 +418,8 @@ body { display: block; padding: 10px 14px; text-decoration: none; - color: #eee; - border-bottom: 1px solid #0f3460; + color: var(--text); + border-bottom: 1px solid var(--border); transition: background 0.15s; } @@ -337,7 +428,7 @@ body { } .search-result:hover { - background: #0f3460; + background: var(--bg-hover); } .search-result-header { @@ -357,19 +448,19 @@ body { font-size: 0.7em; padding: 2px 6px; border-radius: 4px; - background: #0f3460; + background: var(--bg-hover); margin-left: auto; } -.search-status-done { color: #4ecca3; } -.search-status-active { color: #e94560; } -.search-status-review { color: #ffd93d; } -.search-status-ready { color: #6ec6ff; } -.search-status-backlog { color: #888; } +.search-status-done { color: var(--success); } +.search-status-active { color: var(--accent); } +.search-status-review { color: var(--warning); } +.search-status-ready { color: var(--info); } +.search-status-backlog { color: var(--text-muted); } .search-snippet { font-size: 0.75em; - color: #888; + color: var(--text-muted); margin-top: 4px; line-height: 1.4; overflow: hidden; @@ -380,23 +471,18 @@ body { .search-empty { padding: 16px; text-align: center; - color: #888; + color: var(--text-muted); font-size: 0.85em; } -/* Navigation */ +/* === Navigation === */ .nav { display: flex; gap: 8px; align-items: center; } -.btn-active { - background: #0f3460; - border-color: #e94560; -} - -/* Docs — full height */ +/* === Docs === */ .docs-container { padding: 16px 24px; height: calc(100vh - 60px); @@ -414,7 +500,7 @@ body { .docs-sidebar h2 { margin-bottom: 12px; - color: #e94560; + color: var(--accent); font-size: 1.1em; } @@ -429,9 +515,9 @@ body { align-items: center; gap: 8px; padding: 8px 12px; - background: #16213e; + background: var(--bg-panel); border-radius: 6px; - color: #eee; + color: var(--text); text-decoration: none; font-size: 0.9em; font-family: "JetBrains Mono", "Fira Code", monospace; @@ -439,7 +525,7 @@ body { } .doc-item:hover { - background: #0f3460; + background: var(--bg-hover); } .doc-icon { font-size: 1em; } @@ -450,7 +536,7 @@ body { } .docs-breadcrumbs a { - color: #e94560; + color: var(--accent); text-decoration: none; } @@ -459,7 +545,7 @@ body { } .breadcrumb-sep { - color: #555; + color: var(--text-dim); margin: 0 4px; } @@ -473,7 +559,7 @@ body { } .docs-content { - background: #16213e; + background: var(--bg-panel); border-radius: 8px; padding: 24px; line-height: 1.7; @@ -481,24 +567,24 @@ body { } .docs-content h1, .docs-content h2, .docs-content h3 { - color: #e94560; + color: var(--accent); margin-top: 1.2em; margin-bottom: 0.5em; } -.docs-content h1 { font-size: 1.5em; border-bottom: 1px solid #333; padding-bottom: 8px; } +.docs-content h1 { font-size: 1.5em; border-bottom: 1px solid var(--border-light); padding-bottom: 8px; } .docs-content h2 { font-size: 1.2em; } .docs-content h3 { font-size: 1.05em; } .docs-content a { - color: #4ecca3; + color: var(--success); text-decoration: none; } .docs-content a:hover { text-decoration: underline; } .docs-content code { - background: #1a1a2e; + background: var(--bg-page); padding: 2px 6px; border-radius: 3px; font-family: "JetBrains Mono", "Fira Code", monospace; @@ -506,7 +592,7 @@ body { } .docs-content pre { - background: #1a1a2e; + background: var(--bg-page); padding: 12px; border-radius: 6px; overflow-x: auto; @@ -525,13 +611,13 @@ body { } .docs-content th, .docs-content td { - border: 1px solid #333; + border: 1px solid var(--border-light); padding: 8px 12px; text-align: left; } .docs-content th { - background: #0f3460; + background: var(--bg-hover); } .docs-content ul, .docs-content ol { @@ -542,19 +628,19 @@ body { .docs-content li { margin: 4px 0; } .docs-content blockquote { - border-left: 3px solid #e94560; + border-left: 3px solid var(--accent); padding-left: 12px; - color: #aaa; + color: var(--text-secondary); margin: 12px 0; } .docs-content hr { border: none; - border-top: 1px solid #333; + border-top: 1px solid var(--border-light); margin: 16px 0; } -/* Console — fullscreen */ +/* === Console === */ .console-container { padding: 16px; height: calc(100vh - 60px); @@ -574,7 +660,7 @@ body { flex: 1; display: flex; flex-direction: column; - background: #16213e; + background: var(--bg-panel); border-radius: 8px; overflow: hidden; } @@ -584,7 +670,7 @@ body { align-items: center; gap: 8px; padding: 8px 12px; - border-bottom: 1px solid #0f3460; + border-bottom: 1px solid var(--border); font-size: 0.9em; } @@ -592,66 +678,49 @@ body { font-size: 0.75em; padding: 2px 8px; border-radius: 4px; - background: #0f3460; + background: var(--bg-hover); } -.session-idle { color: #888; } -.session-running { color: #4ecca3; } +.session-idle { color: var(--text-muted); } +.session-running { color: var(--success); } .btn-kill { margin-left: auto; - border-color: #e94560; - color: #e94560; + border-color: var(--accent); + color: var(--accent); padding: 4px 10px; font-size: 0.75em; } -.console-output { +.console-terminal { flex: 1; + overflow: hidden; + background: var(--bg-deep); +} + +.console-terminal .xterm { + height: 100%; + padding: 4px; +} + +.console-terminal .xterm-viewport { overflow-y: auto; - padding: 12px; - font-family: "JetBrains Mono", "Fira Code", monospace; - font-size: 0.8em; - line-height: 1.5; - background: #111; -} - -.console-cmd { - color: #4ecca3; - font-weight: bold; - margin-top: 8px; -} - -.console-line { - color: #ddd; - white-space: pre-wrap; - word-break: break-all; -} - -.console-error { - color: #e94560; -} - -.console-done { - color: #666; - margin-top: 4px; - font-style: italic; } .console-input-row { display: flex; gap: 4px; padding: 8px; - border-top: 1px solid #0f3460; + border-top: 1px solid var(--border); flex-shrink: 0; } .console-input { flex: 1; - background: #1a1a2e; - border: 1px solid #333; + background: var(--bg-page); + border: 1px solid var(--border-light); border-radius: 6px; - color: #eee; + color: var(--text); padding: 8px 12px; font-family: "JetBrains Mono", "Fira Code", monospace; font-size: 0.85em; @@ -659,7 +728,7 @@ body { } .console-input:focus { - border-color: #e94560; + border-color: var(--accent); } .console-input:disabled { @@ -673,7 +742,7 @@ body { flex-shrink: 0; } -/* Submit / Prijava */ +/* === Submit / Prijava === */ .submit-container { max-width: 700px; margin: 0 auto; @@ -690,19 +759,19 @@ body { } .btn-mode { - border-color: #333; - color: #888; + border-color: var(--border-light); + color: var(--text-muted); } .btn-mode.active { - border-color: #e94560; - color: #eee; - background: #0f3460; + border-color: var(--accent); + color: var(--text); + background: var(--bg-hover); } .submit-mode h2 { margin-bottom: 16px; - color: #e94560; + color: var(--accent); } .form-group { @@ -713,15 +782,15 @@ body { display: block; margin-bottom: 6px; font-size: 0.9em; - color: #aaa; + color: var(--text-secondary); } .form-input { width: 100%; - background: #1a1a2e; - border: 1px solid #333; + background: var(--bg-page); + border: 1px solid var(--border-light); border-radius: 6px; - color: #eee; + color: var(--text); padding: 10px 14px; font-size: 0.9em; outline: none; @@ -730,7 +799,7 @@ body { } .form-input:focus { - border-color: #e94560; + border-color: var(--accent); } textarea.form-input { @@ -748,7 +817,7 @@ textarea.form-input { align-items: center; gap: 4px; cursor: pointer; - color: #eee; + color: var(--text); } .submit-msg { @@ -760,16 +829,16 @@ textarea.form-input { } .submit-success { - background: #4ecca3; - color: #1a1a2e; + background: var(--success); + color: var(--text-on-color); } .submit-error { - background: #e94560; + background: var(--accent); color: #fff; } -/* Chat (operator mode) */ +/* === Chat (operator mode) === */ #mode-operator { flex-direction: column; flex: 1; @@ -780,7 +849,7 @@ textarea.form-input { flex: 1; overflow-y: auto; padding: 12px; - background: #111; + background: var(--bg-deep); border-radius: 6px; margin-bottom: 12px; min-height: 300px; @@ -799,11 +868,11 @@ textarea.form-input { } .chat-user { - color: #6ec6ff; + color: var(--info); } .chat-bot { - color: #eee; + color: var(--text); } .chat-text { @@ -816,7 +885,12 @@ textarea.form-input { flex-shrink: 0; } -/* Responsive */ +/* === Placeholder text === */ +.text-muted { + color: var(--text-muted); +} + +/* === Responsive === */ @media (max-width: 1100px) { .board { grid-template-columns: repeat(3, 1fr); } } diff --git a/code/web/templates/console.html b/code/web/templates/console.html index cfeacd1..85e342f 100644 --- a/code/web/templates/console.html +++ b/code/web/templates/console.html @@ -5,13 +5,21 @@ KAOS — Konzola + + +

🔧 KAOS Dashboard

+
+ + + +
-
+
- +
@@ -46,19 +54,142 @@ idle -
+
- +
+ + +