Konzola: xterm.js + WebSocket + PTY real-time terminal

- 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 <noreply@anthropic.com>
This commit is contained in:
djuka 2026-02-20 15:31:27 +00:00
parent 7cce5e99c7
commit c970cb2419
9 changed files with 935 additions and 235 deletions

View File

@ -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

View File

@ -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=

View File

@ -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,36 +153,30 @@ 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 {
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()
}

View File

@ -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
}

View File

@ -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)

View File

@ -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")
}
}

146
code/internal/server/ws.go Normal file
View File

@ -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
}

View File

@ -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); }
}

View File

@ -5,13 +5,21 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>KAOS — Konzola</title>
<script>(function(){var m=localStorage.getItem('kaos-theme')||'dark',t=m;if(m==='auto'){t=window.matchMedia('(prefers-color-scheme:light)').matches?'light':'dark'}document.documentElement.setAttribute('data-theme',t)})()</script>
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css">
<script src="/static/htmx.min.js"></script>
<script src="/static/theme.js"></script>
</head>
<body>
<div class="header">
<h1>🔧 KAOS Dashboard</h1>
<div class="header-right">
<div class="theme-toggle">
<button class="theme-btn" data-theme-mode="light" onclick="setTheme('light')" title="Svetla tema">☀️</button>
<button class="theme-btn" data-theme-mode="dark" onclick="setTheme('dark')" title="Tamna tema">🌙</button>
<button class="theme-btn" data-theme-mode="auto" onclick="setTheme('auto')" title="Sistemska tema">🔄</button>
</div>
<nav class="nav">
<a href="/" class="btn">Kanban</a>
<a href="/docs" class="btn">Dokumenti</a>
@ -33,9 +41,9 @@
<span class="session-status" id="status-1">idle</span>
<button class="btn btn-kill" id="kill-1" onclick="killSession(1)" style="display:none">Prekini</button>
</div>
<div class="console-output" id="output-1"></div>
<div class="console-terminal" id="terminal-1"></div>
<div class="console-input-row">
<input type="text" id="input-1" class="console-input" placeholder="Komanda..." onkeydown="handleKey(event, 1)" autocomplete="off">
<input type="text" id="input-1" class="console-input" placeholder="Komanda za claude..." onkeydown="handleKey(event, 1)" autocomplete="off">
<button class="btn btn-move" onclick="sendCommand(1)"></button>
</div>
</div>
@ -46,19 +54,142 @@
<span class="session-status" id="status-2">idle</span>
<button class="btn btn-kill" id="kill-2" onclick="killSession(2)" style="display:none">Prekini</button>
</div>
<div class="console-output" id="output-2"></div>
<div class="console-terminal" id="terminal-2"></div>
<div class="console-input-row">
<input type="text" id="input-2" class="console-input" placeholder="Komanda..." onkeydown="handleKey(event, 2)" autocomplete="off">
<input type="text" id="input-2" class="console-input" placeholder="Komanda za claude..." onkeydown="handleKey(event, 2)" autocomplete="off">
<button class="btn btn-move" onclick="sendCommand(2)"></button>
</div>
</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>
// ── Terminal themes ──────────────────────────────────
var TERM_THEMES = {
dark: {
background: '#111', foreground: '#e0e0e0', cursor: '#e94560', cursorAccent: '#111',
selectionBackground: 'rgba(233,69,96,0.3)',
black: '#111', 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'
},
light: {
background: '#f5f6fa', foreground: '#1e293b', cursor: '#d63851', cursorAccent: '#f5f6fa',
selectionBackground: 'rgba(214,56,81,0.15)',
black: '#1e293b', red: '#dc322f', green: '#859900', yellow: '#b58900',
blue: '#268bd2', magenta: '#d63851', cyan: '#2aa198', white: '#eee8d5',
brightBlack: '#586e75', brightRed: '#cb4b16', brightGreen: '#586e75', brightYellow: '#657b83',
brightBlue: '#839496', brightMagenta: '#6c71c4', brightCyan: '#93a1a1', brightWhite: '#002b36'
}
};
function getTermTheme() {
var t = document.documentElement.getAttribute('data-theme') || 'dark';
return TERM_THEMES[t] || TERM_THEMES.dark;
}
// ── Session state ────────────────────────────────────
var sessions = [{}, {}];
var historyIdx = [0, 0];
var cmdHistory = [[], []];
function initTerminal(idx) {
var num = idx + 1;
var containerEl = document.getElementById('terminal-' + num);
var theme = getTermTheme();
var term = new Terminal({
cursorBlink: true,
cursorStyle: 'block',
cursorInactiveStyle: 'outline',
fontSize: 14,
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace",
theme: theme,
allowProposedApi: true,
scrollback: 10000,
convertEol: false,
drawBoldTextInBrightColors: true
});
var fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.loadAddon(new WebLinksAddon.WebLinksAddon());
term.open(containerEl);
// Keyboard input → WebSocket
term.onData(function(data) {
var ws = sessions[idx].ws;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
});
// Resize → WebSocket
term.onResize(function(size) {
var ws = sessions[idx].ws;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows }));
}
});
containerEl.addEventListener('click', function() { term.focus(); });
sessions[idx].term = term;
sessions[idx].fitAddon = fitAddon;
sessions[idx].ws = null;
setTimeout(function() { fitAddon.fit(); }, 50);
}
// ── WebSocket connection ─────────────────────────────
function connectWS(idx) {
var num = idx + 1;
var sess = sessions[idx];
if (sess.ws) {
sess.ws.close();
sess.ws = null;
}
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
var url = proto + '//' + location.host + '/console/ws/' + num;
var ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
ws.onopen = function() {
setSessionUI(num, 'running');
// Send initial size
var term = sess.term;
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
term.focus();
};
ws.onmessage = function(event) {
if (event.data instanceof ArrayBuffer) {
sess.term.write(new Uint8Array(event.data));
} else {
sess.term.write(event.data);
}
};
ws.onclose = function() {
sess.ws = null;
setSessionUI(num, 'idle');
};
ws.onerror = function() {
sess.ws = null;
setSessionUI(num, 'idle');
};
sess.ws = ws;
}
// ── Command handling ─────────────────────────────────
function handleKey(e, session) {
if (e.key === 'Enter') {
sendCommand(session);
@ -87,11 +218,11 @@ function sendCommand(session) {
var idx = session - 1;
cmdHistory[idx].push(cmd);
historyIdx[idx] = cmdHistory[idx].length;
var output = document.getElementById('output-' + session);
output.innerHTML += '<div class="console-cmd">&gt; ' + escapeHtml(cmd) + '</div>';
input.value = '';
// Clear terminal for new command
sessions[idx].term.clear();
setSessionUI(session, 'running');
fetch('/console/exec', {
@ -102,7 +233,7 @@ function sendCommand(session) {
.then(function(resp) {
if (!resp.ok) {
return resp.json().then(function(data) {
output.innerHTML += '<div class="console-error">' + escapeHtml(data.error) + '</div>';
sessions[idx].term.write('\r\n\x1b[31m' + data.error + '\x1b[0m\r\n');
setSessionUI(session, 'idle');
throw new Error(data.error);
});
@ -111,40 +242,23 @@ function sendCommand(session) {
})
.then(function(data) {
if (!data) return;
streamOutput(session, data.exec_id);
// Connect WebSocket to the PTY session
connectWS(idx);
})
.catch(function(err) {
setSessionUI(session, 'idle');
});
}
function streamOutput(session, execId) {
var output = document.getElementById('output-' + session);
var source = new EventSource('/console/stream/' + execId);
source.onmessage = function(e) {
output.innerHTML += '<div class="console-line">' + escapeHtml(e.data) + '</div>';
output.scrollTop = output.scrollHeight;
};
source.addEventListener('done', function(e) {
source.close();
output.innerHTML += '<div class="console-done">--- gotovo ---</div>';
output.scrollTop = output.scrollHeight;
setSessionUI(session, 'idle');
});
source.onerror = function() {
source.close();
setSessionUI(session, 'idle');
};
}
function killSession(session) {
fetch('/console/kill/' + session, {method: 'POST'})
.then(function() {
var output = document.getElementById('output-' + session);
output.innerHTML += '<div class="console-error">--- prekinuto ---</div>';
var idx = session - 1;
sessions[idx].term.write('\r\n\x1b[33m--- prekinuto ---\x1b[0m\r\n');
if (sessions[idx].ws) {
sessions[idx].ws.close();
sessions[idx].ws = null;
}
setSessionUI(session, 'idle');
});
}
@ -153,7 +267,6 @@ function setSessionUI(session, status) {
document.getElementById('status-' + session).textContent = status;
document.getElementById('status-' + session).className = 'session-status session-' + status;
document.getElementById('kill-' + session).style.display = status === 'running' ? 'inline-block' : 'none';
document.getElementById('input-' + session).disabled = status === 'running';
}
function togglePanel2() {
@ -162,17 +275,44 @@ function togglePanel2() {
if (panel.style.display === 'none') {
panel.style.display = 'flex';
btn.textContent = '- Sesija 2';
// Initialize terminal 2 if not yet done
if (!sessions[1].term) {
initTerminal(1);
} else {
sessions[1].fitAddon.fit();
}
} else {
panel.style.display = 'none';
btn.textContent = '+ Sesija 2';
}
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
// ── Theme sync ───────────────────────────────────────
var origSetTheme = window.setTheme;
window.setTheme = function(mode) {
if (origSetTheme) origSetTheme(mode);
// Update terminal themes after a tick
setTimeout(function() {
var theme = getTermTheme();
for (var i = 0; i < 2; i++) {
if (sessions[i].term) {
sessions[i].term.options.theme = theme;
}
}
}, 50);
};
// ── Window resize ────────────────────────────────────
window.addEventListener('resize', function() {
for (var i = 0; i < 2; i++) {
if (sessions[i].fitAddon) {
sessions[i].fitAddon.fit();
}
}
});
// ── Initialize ───────────────────────────────────────
initTerminal(0);
</script>
</body>
</html>