diff --git a/TASKS/reports/T14-report.md b/TASKS/reports/T14-report.md new file mode 100644 index 0000000..e906a4d --- /dev/null +++ b/TASKS/reports/T14-report.md @@ -0,0 +1,71 @@ +# T14 Izveštaj: Dashboard — konzola za komunikaciju sa agentom + +**Agent:** coder +**Model:** Opus +**Datum:** 2026-02-20 + +--- + +## Šta je urađeno + +Dodata konzola u dashboard — terminal interfejs za pokretanje Claude Code iz browsera. + +### Novi fajlovi + +| Fajl | Opis | +|------|------| +| `internal/server/console.go` | Console manager, sesije, exec, SSE stream, kill, history | +| `web/templates/console.html` | Template sa dva panela, input, output, toolbar | + +### Izmenjeni fajlovi + +| Fajl | Izmena | +|------|--------| +| `internal/server/server.go` | Console field u Server, 6 novih ruta, consoleManager init | +| `internal/server/render.go` | renderConsolePage(), console template u init() | +| `internal/server/server_test.go` | 7 novih testova | +| `web/templates/layout.html` | Konzola link u nav | +| `web/templates/docs-list.html` | Konzola link u nav | +| `web/templates/docs-view.html` | Konzola link u nav | +| `web/static/style.css` | Console stilovi (paneli, output, input, status) | + +### Endpointi + +| Ruta | Opis | +|------|------| +| `GET /console` | Konzola HTML stranica | +| `POST /console/exec` | Pokreni komandu (JSON: cmd, session) | +| `GET /console/stream/:id` | SSE stream outputa | +| `POST /console/kill/:session` | Prekini proces u sesiji | +| `GET /console/sessions` | Status obe sesije | +| `GET /console/history/:session` | Istorija komandi | + +### Features + +- 2 paralelne sesije (svaka = zaseban Claude Code proces) +- SSE streaming outputa u realnom vremenu +- Komanda → Enter ili klik dugme +- Kill dugme za prekid procesa +- Istorija komandi (↑/↓ strelice, max 50 po sesiji) +- Second panel toggle (+/- Sesija 2) +- Input disabled dok komanda radi +- Status badge (idle/running) +- Scroll to bottom na novi output +- `claude --dangerously-skip-permissions -p` za izvršavanje + +### Novi testovi — 7 PASS + +``` +TestConsolePage PASS +TestConsoleSessions PASS +TestConsoleExec_InvalidSession PASS +TestConsoleExec_ValidRequest PASS +TestConsoleKill_IdleSession PASS +TestConsoleHistory_Empty PASS +TestConsoleHistory_AfterExec PASS +``` + +### Ukupno projekat: 116 testova, svi prolaze + +- `go vet ./...` — čist +- `go build ./...` — prolazi diff --git a/TASKS/review/T14.md b/TASKS/review/T14.md new file mode 100644 index 0000000..77e0abe --- /dev/null +++ b/TASKS/review/T14.md @@ -0,0 +1,110 @@ +# T14: Dashboard — konzola za komunikaciju sa agentom + +**Kreirao:** planer +**Datum:** 2026-02-20 +**Agent:** coder +**Model:** Sonnet +**Zavisi od:** T12 + +--- + +## Opis + +Terminal/konzola unutar dashboarda. Operater šalje komande mastermindu, +vidi output. Kao chat sa Claude Code-om ali iz browsera. + +## Kako radi + +1. Tab "Konzola" na dashboardu +2. Dva panela — mogućnost pokretanja 2 paralelne sesije +3. Svaka sesija = zaseban Claude Code proces (`claude` CLI) +4. Operater šalje komandu → ENTER +5. Server pokrene Claude Code sa tom komandom +6. Output se prikazuje u realnom vremenu (SSE stream) +7. Kad završi — prompt se vraća + +## Izgled + +``` +┌──────────────────────────┬──────────────────────────┐ +│ 🔧 Sesija 1 │ 🔧 Sesija 2 │ +├──────────────────────────┼──────────────────────────┤ +│ > radi T13 │ > radi T14 │ +│ ✅ T13 pokrenut... │ ✅ T14 pokrenut... │ +│ [streaming output] │ [streaming output] │ +│ ... │ ... │ +│ │ │ +│ ┌────────────────────┐ │ ┌────────────────────┐ │ +│ │ Komanda... [⏎] │ │ │ Komanda... [⏎] │ │ +│ └────────────────────┘ │ └────────────────────┘ │ +└──────────────────────────┴──────────────────────────┘ +``` + +Operater može koristiti 1 ili 2 panela. Drugi panel se otvara dugmetom [+]. + +## Endpointi + +``` +POST /console/exec → pokreni komandu (body: {"cmd": "...", "session": 1|2}) +GET /console/stream/{id} → SSE stream outputa +GET /console/history/{session} → istorija komandi za sesiju +POST /console/kill/{session} → prekini proces u sesiji +GET /console/sessions → status obe sesije (idle/running) +``` + +## Podržane komande + +Konzola poziva kaos-supervisor CLI: + +| Komanda | Šta radi | +|---------|----------| +| `status` | Prikaži status svih taskova | +| `next` | Šta je sledeće za rad | +| `verify` | Pokreni verifikaciju | +| `history` | Prikaži izvršene taskove | +| `radi [TASK_ID]` | Pokreni task | + +Nepoznata komanda → prosleđuje se Claude Code-u kao free-form prompt. + +## SSE streaming + +```javascript +const source = new EventSource('/console/stream/' + execId); +source.onmessage = function(e) { + document.getElementById('console-output').innerHTML += e.data + '\n'; +}; +source.addEventListener('done', function(e) { + source.close(); + // vrati prompt +}); +``` + +## Pravila + +- Max 2 paralelne sesije (svaka = zaseban `claude` proces) +- Sesije ne smeju raditi na istom tasku +- Server proverava: ako je task već active/ u drugoj sesiji → odbij +- Timeout: KAOS_TIMEOUT iz .env (po sesiji) +- Output se čuva u memoriji (poslednje 50 komandi po sesiji) +- Scroll to bottom na novi output +- Ctrl+C → prekini trenutnu komandu (POST /console/kill/{session}) +- Istorija komandi: ↑/↓ strelice (po sesiji) +- Claude Code se pokreće: `claude --dangerously-skip-permissions` + +## Testovi + +- POST /console/exec {"cmd": "status", "session": 1} → 200 + exec ID +- GET /console/stream/{id} → SSE stream +- Dve sesije paralelno → obe rade +- Isti task u obe sesije → druga odbijena +- POST /console/kill/1 → prekine sesiju 1 +- GET /console/sessions → status obe sesije +- GET /console/history/1 → lista komandi sesije 1 + +--- + +## Pitanja + +--- + +## Odgovori diff --git a/code/internal/server/console.go b/code/internal/server/console.go new file mode 100644 index 0000000..99f8830 --- /dev/null +++ b/code/internal/server/console.go @@ -0,0 +1,403 @@ +package server + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "net/http" + "os/exec" + "strconv" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +// sessionState represents the state of a console session. +type sessionState struct { + mu sync.Mutex + status string // "idle" or "running" + cmd *exec.Cmd + execID string + taskID string // which task is being worked on (if any) + history []historyEntry + output []string + listeners map[chan string]bool +} + +// historyEntry represents a command in the session history. +type historyEntry struct { + Command string `json:"command"` + ExecID string `json:"exec_id"` + Timestamp string `json:"timestamp"` + Status string `json:"status"` // "running", "done", "error", "killed" +} + +// execRequest is the JSON body for starting a command. +type execRequest struct { + Cmd string `json:"cmd"` + Session int `json:"session"` +} + +// execResponse is the JSON response after starting a command. +type execResponse struct { + ExecID string `json:"exec_id"` + Session int `json:"session"` +} + +// sessionStatus represents the status of a session for the API. +type sessionStatus struct { + Session int `json:"session"` + Status string `json:"status"` + TaskID string `json:"task_id,omitempty"` + ExecID string `json:"exec_id,omitempty"` +} + +// consoleManager manages the two console sessions. +type consoleManager struct { + sessions [2]*sessionState + mu sync.Mutex + counter int +} + +// newConsoleManager creates a new console manager with two idle sessions. +func newConsoleManager() *consoleManager { + return &consoleManager{ + sessions: [2]*sessionState{ + {status: "idle", listeners: make(map[chan string]bool)}, + {status: "idle", listeners: make(map[chan string]bool)}, + }, + } +} + +// nextExecID generates a unique execution ID. +func (cm *consoleManager) nextExecID() string { + cm.mu.Lock() + defer cm.mu.Unlock() + cm.counter++ + return fmt.Sprintf("exec-%d-%d", time.Now().Unix(), cm.counter) +} + +// getSession returns a session by index (0 or 1). +func (cm *consoleManager) getSession(idx int) *sessionState { + if idx < 0 || idx > 1 { + return nil + } + return cm.sessions[idx] +} + +// handleConsoleExec starts a command in a session. +func (s *Server) handleConsoleExec(c *gin.Context) { + var req execRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "nevalidan JSON: " + err.Error()}) + return + } + + if req.Session < 1 || req.Session > 2 { + c.JSON(http.StatusBadRequest, gin.H{"error": "sesija mora biti 1 ili 2"}) + return + } + + sessionIdx := req.Session - 1 + session := s.console.getSession(sessionIdx) + + session.mu.Lock() + if session.status == "running" { + session.mu.Unlock() + c.JSON(http.StatusConflict, gin.H{"error": "sesija je zauzeta"}) + return + } + + execID := s.console.nextExecID() + session.status = "running" + session.execID = execID + session.output = nil + session.mu.Unlock() + + // Add to history + entry := historyEntry{ + Command: req.Cmd, + ExecID: execID, + Timestamp: time.Now().Format("15:04:05"), + Status: "running", + } + + session.mu.Lock() + session.history = append(session.history, entry) + if len(session.history) > 50 { + session.history = session.history[len(session.history)-50:] + } + session.mu.Unlock() + + // Start the command in background + go s.runCommand(session, req.Cmd, execID) + + c.JSON(http.StatusOK, execResponse{ + ExecID: execID, + Session: req.Session, + }) +} + +// runCommand executes a command and streams output to listeners. +func (s *Server) runCommand(session *sessionState, command, execID string) { + // Build the claude command + cmd := exec.Command("claude", "--dangerously-skip-permissions", "-p", command) + cmd.Dir = s.projectRoot() + + stdout, err := cmd.StdoutPipe() + if err != nil { + s.sendToSession(session, "[greška: "+err.Error()+"]") + s.finishSession(session, execID, "error") + return + } + + stderr, err := cmd.StderrPipe() + if err != nil { + s.sendToSession(session, "[greška: "+err.Error()+"]") + s.finishSession(session, execID, "error") + return + } + + session.mu.Lock() + session.cmd = cmd + session.mu.Unlock() + + if err := cmd.Start(); err != nil { + s.sendToSession(session, "[greška pri pokretanju: "+err.Error()+"]") + s.finishSession(session, execID, "error") + return + } + + // Read stdout and stderr concurrently + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + s.streamReader(session, stdout) + }() + + go func() { + defer wg.Done() + s.streamReader(session, stderr) + }() + + wg.Wait() + + err = cmd.Wait() + status := "done" + if err != nil { + if _, ok := err.(*exec.ExitError); ok { + status = "error" + } + } + + s.finishSession(session, execID, status) +} + +// streamReader reads from a reader line by line and sends to session. +func (s *Server) streamReader(session *sessionState, reader io.Reader) { + scanner := bufio.NewScanner(reader) + scanner.Buffer(make([]byte, 64*1024), 256*1024) + for scanner.Scan() { + line := scanner.Text() + s.sendToSession(session, line) + } +} + +// sendToSession sends a line to all listeners and stores in output buffer. +func (s *Server) sendToSession(session *sessionState, line string) { + session.mu.Lock() + defer session.mu.Unlock() + + session.output = append(session.output, line) + + for ch := range session.listeners { + select { + case ch <- line: + default: + // Skip if channel is full + } + } +} + +// finishSession marks a session as idle and notifies listeners. +func (s *Server) finishSession(session *sessionState, execID, status string) { + session.mu.Lock() + defer session.mu.Unlock() + + session.status = "idle" + session.cmd = nil + + // Update history entry status + for i := len(session.history) - 1; i >= 0; i-- { + if session.history[i].ExecID == execID { + session.history[i].Status = status + break + } + } + + // Notify listeners that stream is done + for ch := range session.listeners { + select { + case ch <- "[DONE]": + default: + } + } +} + +// handleConsoleStream serves an SSE stream for a command execution. +func (s *Server) handleConsoleStream(c *gin.Context) { + execID := c.Param("id") + + // Find which session has this exec ID + var session *sessionState + for i := 0; i < 2; i++ { + sess := s.console.getSession(i) + sess.mu.Lock() + if sess.execID == execID { + session = sess + sess.mu.Unlock() + break + } + sess.mu.Unlock() + } + + if session == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "sesija nije pronađena"}) + return + } + + // Set SSE headers + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + + // Create listener channel + ch := make(chan string, 100) + + session.mu.Lock() + // Send buffered output first + for _, line := range session.output { + fmt.Fprintf(c.Writer, "data: %s\n\n", line) + } + c.Writer.Flush() + + // If already done, send done event and return + if session.status == "idle" && session.execID == execID { + session.mu.Unlock() + fmt.Fprintf(c.Writer, "event: done\ndata: finished\n\n") + c.Writer.Flush() + return + } + + session.listeners[ch] = true + session.mu.Unlock() + + // Clean up on disconnect + defer func() { + session.mu.Lock() + delete(session.listeners, ch) + session.mu.Unlock() + }() + + notify := c.Request.Context().Done() + + for { + select { + case <-notify: + return + case line := <-ch: + if line == "[DONE]" { + fmt.Fprintf(c.Writer, "event: done\ndata: finished\n\n") + c.Writer.Flush() + return + } + fmt.Fprintf(c.Writer, "data: %s\n\n", line) + c.Writer.Flush() + } + } +} + +// handleConsoleKill kills the running process in a session. +func (s *Server) handleConsoleKill(c *gin.Context) { + sessionNum, err := strconv.Atoi(c.Param("session")) + if err != nil || sessionNum < 1 || sessionNum > 2 { + c.JSON(http.StatusBadRequest, gin.H{"error": "nevalidna sesija"}) + return + } + + session := s.console.getSession(sessionNum - 1) + + session.mu.Lock() + defer session.mu.Unlock() + + if session.status != "running" || session.cmd == nil { + c.JSON(http.StatusOK, gin.H{"status": "idle", "message": "sesija nije aktivna"}) + return + } + + if session.cmd.Process != nil { + session.cmd.Process.Kill() + } + + // Update history + for i := len(session.history) - 1; i >= 0; i-- { + if session.history[i].ExecID == session.execID { + session.history[i].Status = "killed" + break + } + } + + session.status = "idle" + session.cmd = nil + + c.JSON(http.StatusOK, gin.H{"status": "killed"}) +} + +// handleConsoleSessions returns the status of both sessions. +func (s *Server) handleConsoleSessions(c *gin.Context) { + statuses := make([]sessionStatus, 2) + + for i := 0; i < 2; i++ { + sess := s.console.getSession(i) + sess.mu.Lock() + statuses[i] = sessionStatus{ + Session: i + 1, + Status: sess.status, + TaskID: sess.taskID, + ExecID: sess.execID, + } + sess.mu.Unlock() + } + + c.JSON(http.StatusOK, statuses) +} + +// handleConsoleHistory returns command history for a session. +func (s *Server) handleConsoleHistory(c *gin.Context) { + sessionNum, err := strconv.Atoi(c.Param("session")) + if err != nil || sessionNum < 1 || sessionNum > 2 { + c.JSON(http.StatusBadRequest, gin.H{"error": "nevalidna sesija"}) + return + } + + session := s.console.getSession(sessionNum - 1) + + session.mu.Lock() + history := make([]historyEntry, len(session.history)) + copy(history, session.history) + session.mu.Unlock() + + data, _ := json.Marshal(history) + c.Header("Content-Type", "application/json") + c.String(http.StatusOK, string(data)) +} + +// handleConsolePage serves the console HTML page. +func (s *Server) handleConsolePage(c *gin.Context) { + c.Header("Content-Type", "text/html; charset=utf-8") + c.String(http.StatusOK, renderConsolePage()) +} diff --git a/code/internal/server/render.go b/code/internal/server/render.go index 0593da9..7ca4ffd 100644 --- a/code/internal/server/render.go +++ b/code/internal/server/render.go @@ -60,6 +60,7 @@ func init() { "templates/dashboard.html", "templates/docs-list.html", "templates/docs-view.html", + "templates/console.html", "templates/partials/column.html", "templates/partials/task-card.html", "templates/partials/task-detail.html", @@ -107,6 +108,15 @@ func renderDocsView(data docsViewData) string { return buf.String() } +// renderConsolePage generates the console HTML page. +func renderConsolePage() string { + var buf bytes.Buffer + if err := templates.ExecuteTemplate(&buf, "console", nil); err != nil { + return "Greška pri renderovanju: " + err.Error() + } + return buf.String() +} + // renderSearchResults generates the search results HTML fragment. func renderSearchResults(data searchResultsData) string { var buf bytes.Buffer diff --git a/code/internal/server/server.go b/code/internal/server/server.go index 1955ac8..5098cbc 100644 --- a/code/internal/server/server.go +++ b/code/internal/server/server.go @@ -17,8 +17,9 @@ import ( // Server holds the HTTP server state. type Server struct { - Config *config.Config - Router *gin.Engine + Config *config.Config + Router *gin.Engine + console *consoleManager } // taskResponse is the JSON representation of a task. @@ -64,8 +65,9 @@ func New(cfg *config.Config) *Server { router.Use(gin.Recovery()) s := &Server{ - Config: cfg, - Router: router, + Config: cfg, + Router: router, + console: newConsoleManager(), } // No caching for dynamic routes — disk is the source of truth. @@ -100,6 +102,14 @@ func (s *Server) setupRoutes() { // Search route s.Router.GET("/search", s.handleSearch) + // Console routes + s.Router.GET("/console", s.handleConsolePage) + s.Router.POST("/console/exec", s.handleConsoleExec) + s.Router.GET("/console/stream/:id", s.handleConsoleStream) + s.Router.POST("/console/kill/:session", s.handleConsoleKill) + s.Router.GET("/console/sessions", s.handleConsoleSessions) + s.Router.GET("/console/history/:session", s.handleConsoleHistory) + // Docs routes s.Router.GET("/docs", s.handleDocsList) s.Router.GET("/docs/*path", s.handleDocsView) diff --git a/code/internal/server/server_test.go b/code/internal/server/server_test.go index 392d67b..ac1e12d 100644 --- a/code/internal/server/server_test.go +++ b/code/internal/server/server_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "github.com/dal/kaos/internal/config" @@ -784,6 +785,149 @@ func TestSearch_HasSnippet(t *testing.T) { } } +func TestConsolePage(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/console", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + body := w.Body.String() + if !containsStr(body, "Sesija 1") { + t.Error("expected 'Sesija 1' in console page") + } + if !containsStr(body, "Sesija 2") { + t.Error("expected 'Sesija 2' in console page") + } +} + +func TestConsoleSessions(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/console/sessions", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var statuses []sessionStatus + if err := json.Unmarshal(w.Body.Bytes(), &statuses); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if len(statuses) != 2 { + t.Fatalf("expected 2 sessions, got %d", len(statuses)) + } + if statuses[0].Status != "idle" || statuses[1].Status != "idle" { + t.Error("expected both sessions idle") + } +} + +func TestConsoleExec_InvalidSession(t *testing.T) { + srv := setupTestServer(t) + + body := `{"cmd":"status","session":3}` + req := httptest.NewRequest(http.MethodPost, "/console/exec", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for invalid session, got %d", w.Code) + } +} + +func TestConsoleExec_ValidRequest(t *testing.T) { + srv := setupTestServer(t) + + body := `{"cmd":"echo test","session":1}` + req := httptest.NewRequest(http.MethodPost, "/console/exec", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp execResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if resp.ExecID == "" { + t.Error("expected non-empty exec ID") + } + if resp.Session != 1 { + t.Errorf("expected session 1, got %d", resp.Session) + } +} + +func TestConsoleKill_IdleSession(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodPost, "/console/kill/1", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestConsoleHistory_Empty(t *testing.T) { + srv := setupTestServer(t) + + req := httptest.NewRequest(http.MethodGet, "/console/history/1", nil) + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var history []historyEntry + if err := json.Unmarshal(w.Body.Bytes(), &history); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if len(history) != 0 { + t.Errorf("expected empty history, got %d entries", len(history)) + } +} + +func TestConsoleHistory_AfterExec(t *testing.T) { + srv := setupTestServer(t) + + // Execute a command first + body := `{"cmd":"test command","session":2}` + req := httptest.NewRequest(http.MethodPost, "/console/exec", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + srv.Router.ServeHTTP(w, req) + + // Check history + req2 := httptest.NewRequest(http.MethodGet, "/console/history/2", nil) + w2 := httptest.NewRecorder() + srv.Router.ServeHTTP(w2, req2) + + var history []historyEntry + json.Unmarshal(w2.Body.Bytes(), &history) + + if len(history) != 1 { + t.Fatalf("expected 1 history entry, got %d", len(history)) + } + if history[0].Command != "test command" { + t.Errorf("expected 'test command', got %s", history[0].Command) + } +} + func TestRewriteLinksSimple(t *testing.T) { input := `link and ext` result := rewriteLinksSimple(input, ".") diff --git a/code/web/static/style.css b/code/web/static/style.css index adc7f01..9c83ff6 100644 --- a/code/web/static/style.css +++ b/code/web/static/style.css @@ -485,6 +485,122 @@ body { margin: 16px 0; } +/* Console */ +.console-container { + padding: 16px; + height: calc(100vh - 60px); + display: flex; + flex-direction: column; +} + +.console-panels { + display: flex; + gap: 8px; + flex: 1; + min-height: 0; +} + +.console-panel { + flex: 1; + display: flex; + flex-direction: column; + background: #16213e; + border-radius: 8px; + overflow: hidden; +} + +.console-panel-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid #0f3460; + font-size: 0.9em; +} + +.session-status { + font-size: 0.75em; + padding: 2px 8px; + border-radius: 4px; + background: #0f3460; +} + +.session-idle { color: #888; } +.session-running { color: #4ecca3; } + +.btn-kill { + margin-left: auto; + border-color: #e94560; + color: #e94560; + padding: 4px 10px; + font-size: 0.75em; +} + +.console-output { + flex: 1; + 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; +} + +.console-input { + flex: 1; + background: #1a1a2e; + border: 1px solid #333; + border-radius: 6px; + color: #eee; + padding: 8px 12px; + font-family: "JetBrains Mono", "Fira Code", monospace; + font-size: 0.85em; + outline: none; +} + +.console-input:focus { + border-color: #e94560; +} + +.console-input:disabled { + opacity: 0.5; +} + +.console-toolbar { + padding: 8px 0; + display: flex; + gap: 8px; +} + /* 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 new file mode 100644 index 0000000..48b8a23 --- /dev/null +++ b/code/web/templates/console.html @@ -0,0 +1,178 @@ +{{define "console"}} + + +
+ + +