diff --git a/src/cockpit/sidebar.js b/src/cockpit/sidebar.js new file mode 100644 index 00000000..b65ea70c --- /dev/null +++ b/src/cockpit/sidebar.js @@ -0,0 +1,206 @@ +const path = require('node:path'); + +const DEFAULT_WIDTH = 36; +const MIN_WIDTH = 12; + +const STATUS_DOTS = new Map([ + ['active', '*'], + ['running', '*'], + ['working', '*'], + ['thinking', 'o'], + ['idle', 'o'], + ['ready', 'o'], + ['done', '+'], + ['complete', '+'], + ['completed', '+'], + ['merged', '+'], + ['blocked', '!'], + ['error', '!'], + ['failed', '!'], + ['stalled', '!'], + ['dead', '!'], +]); + +const ANSI = { + reset: '\x1b[0m', + dim: '\x1b[2m', + green: '\x1b[32m', + red: '\x1b[31m', + yellow: '\x1b[33m', + cyan: '\x1b[36m', + inverse: '\x1b[7m', +}; + +function text(value, fallback = '') { + if (typeof value === 'string') { + return value.trim() || fallback; + } + if (value === null || value === undefined) { + return fallback; + } + return String(value).trim() || fallback; +} + +function sidebarWidth(options = {}) { + const width = Number(options.width); + if (!Number.isFinite(width)) { + return DEFAULT_WIDTH; + } + return Math.max(MIN_WIDTH, Math.floor(width)); +} + +function truncate(value, width) { + const raw = value === null || value === undefined ? '' : String(value); + if (width <= 0) { + return ''; + } + if (raw.length <= width) { + return raw; + } + if (width <= 3) { + return raw.slice(0, width); + } + return `${raw.slice(0, width - 3)}...`; +} + +function boundLine(value, width) { + return truncate(value, width); +} + +function repoName(state = {}, options = {}) { + const explicit = text(options.repoName || state.repoName || state.projectName || state.repo); + if (explicit) { + return explicit; + } + + const repoPath = text(state.repoPath); + if (!repoPath) { + return '-'; + } + return path.basename(repoPath) || repoPath; +} + +function agentLabel(agentName) { + const compact = text(agentName, 'agent').replace(/[^a-z0-9]/gi, '').toUpperCase(); + return truncate(compact || 'AGENT', 3).padEnd(3, ' '); +} + +function statusDot(session = {}) { + if (session.worktreeExists === false) { + return 'x'; + } + const status = text(session.status, 'unknown').toLowerCase(); + return STATUS_DOTS.get(status) || '.'; +} + +function lockCount(session = {}) { + if (Array.isArray(session.locks)) { + return session.locks.length; + } + const count = Number(session.lockCount); + return Number.isFinite(count) && count >= 0 ? count : 0; +} + +function sessionId(session = {}) { + return text(session.id || session.sessionId || session.branch); +} + +function isSelected(session, index, state = {}, options = {}) { + const selectedId = text(options.selectedId || options.selectedSessionId || state.selectedId || state.selectedSessionId); + if (selectedId && sessionId(session) === selectedId) { + return true; + } + + const selectedBranch = text(options.selectedBranch || state.selectedBranch); + if (selectedBranch && text(session.branch) === selectedBranch) { + return true; + } + + const selectedIndex = Number.isInteger(options.selectedIndex) ? options.selectedIndex : state.selectedIndex; + return Number.isInteger(selectedIndex) && selectedIndex === index; +} + +function colorize(value, color, options = {}) { + if (options.noColor || options.color !== true) { + return value; + } + const code = ANSI[color]; + return code ? `${code}${value}${ANSI.reset}` : value; +} + +function statusColor(dot) { + if (dot === '*') { + return 'green'; + } + if (dot === '!') { + return 'yellow'; + } + if (dot === 'x') { + return 'red'; + } + if (dot === '+') { + return 'cyan'; + } + return 'dim'; +} + +function renderSessionRow(session, index, state, options) { + const width = sidebarWidth(options); + const selected = isSelected(session, index, state, options); + const marker = selected ? '>' : ' '; + const dot = statusDot(session); + const label = agentLabel(session.agentName); + const branch = text(session.branch, '(no branch)'); + const task = text(session.task, '(no task)'); + const missing = session.worktreeExists === false ? ' missing worktree' : ''; + + const firstPrefix = `${marker} ${dot} ${label} `; + const first = `${firstPrefix}${truncate(branch, width - firstPrefix.length)}`; + const taskPrefix = ' '; + const taskLine = `${taskPrefix}${truncate(task, width - taskPrefix.length)}`; + const meta = ` locks: ${lockCount(session)}${missing}`; + + return [ + selected ? colorize(boundLine(first, width), 'inverse', options) : boundLine(first, width), + boundLine(taskLine, width), + colorize(boundLine(meta, width), statusColor(dot), options), + ]; +} + +function renderSidebar(state = {}, options = {}) { + const width = sidebarWidth(options); + const title = text(options.title || state.title, 'gx cockpit').toLowerCase() === 'gitguardex' + ? 'gitguardex' + : text(options.title || state.title, 'gx cockpit'); + const sessions = Array.isArray(state.sessions) ? state.sessions : []; + const lines = [ + boundLine(title, width), + boundLine(`repo ${repoName(state, options)}`, width), + boundLine('-'.repeat(width), width), + boundLine('lanes', width), + ]; + + if (sessions.length === 0) { + lines.push(boundLine(' no active lanes', width)); + } else { + sessions.forEach((session, index) => { + lines.push(...renderSessionRow(session, index, state, options)); + }); + } + + lines.push( + boundLine('-'.repeat(width), width), + boundLine('[n] new agent', width), + boundLine('[t] terminal', width), + boundLine('[s] settings', width), + ); + + return `${lines.join('\n')}\n`; +} + +module.exports = { + renderSidebar, + agentLabel, + statusDot, + truncate, +}; diff --git a/test/cockpit-sidebar.test.js b/test/cockpit-sidebar.test.js new file mode 100644 index 00000000..d0ef92dd --- /dev/null +++ b/test/cockpit-sidebar.test.js @@ -0,0 +1,113 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { renderSidebar } = require('../src/cockpit/sidebar'); + +function lines(output) { + return output.trimEnd().split('\n'); +} + +test('renderSidebar renders an empty sidebar', () => { + const output = renderSidebar({ + repoPath: '/work/gitguardex', + sessions: [], + }, { noColor: true }); + + assert.match(output, /gx cockpit/); + assert.match(output, /repo gitguardex/); + assert.match(output, /lanes/); + assert.match(output, /no active lanes/); + assert.match(output, /\[n\] new agent/); + assert.match(output, /\[t\] terminal/); + assert.match(output, /\[s\] settings/); +}); + +test('renderSidebar marks the selected session', () => { + const output = renderSidebar({ + repoName: 'gitguardex', + selectedSessionId: 's2', + sessions: [ + { + id: 's1', + agentName: 'codex', + branch: 'agent/codex/first', + task: 'first lane', + status: 'idle', + lockCount: 0, + worktreeExists: true, + }, + { + id: 's2', + agentName: 'claude', + branch: 'agent/claude/second', + task: 'selected lane', + status: 'working', + lockCount: 2, + worktreeExists: true, + }, + ], + }, { noColor: true }); + + assert.match(output, /^ o COD agent\/codex\/first$/m); + assert.match(output, /^> \* CLA agent\/claude\/second$/m); +}); + +test('renderSidebar marks a missing worktree', () => { + const output = renderSidebar({ + repoName: 'gitguardex', + sessions: [ + { + id: 'missing', + agentName: 'codex', + branch: 'agent/codex/missing', + task: 'repair missing lane', + status: 'stalled', + lockCount: 1, + worktreeExists: false, + }, + ], + }, { noColor: true }); + + assert.match(output, /^ x COD agent\/codex\/missing$/m); + assert.match(output, /locks: 1 missing worktree/); +}); + +test('renderSidebar truncates long branch and task text', () => { + const output = renderSidebar({ + repoName: 'gitguardex', + sessions: [ + { + id: 'long', + agentName: 'codex', + branch: 'agent/codex/this-branch-name-is-too-long-for-the-sidebar', + task: 'this task description is also too long for the bounded dmux-style sidebar', + status: 'working', + lockCount: 0, + worktreeExists: true, + }, + ], + }, { width: 30, noColor: true }); + + assert.ok(lines(output).every((line) => line.length <= 30)); + assert.match(output, /agent\/codex\/this-br\.\.\./); + assert.match(output, /this task description i\.\.\./); +}); + +test('renderSidebar displays lock counts', () => { + const output = renderSidebar({ + repoName: 'gitguardex', + sessions: [ + { + id: 'locks', + agentName: 'codex', + branch: 'agent/codex/locks', + task: 'lock count lane', + status: 'working', + lockCount: 7, + worktreeExists: true, + }, + ], + }, { noColor: true }); + + assert.match(output, /locks: 7/); +});