diff --git a/src/cockpit/control.js b/src/cockpit/control.js index 62886e8..3328c49 100644 --- a/src/cockpit/control.js +++ b/src/cockpit/control.js @@ -3,6 +3,8 @@ const { readCockpitState } = require('./state'); const { renderSidebar } = require('./sidebar'); const { renderSettingsScreen } = require('./settings-render'); +const { CONTROL_KEY_HELP } = require('./shortcuts'); +const { stripAnsi } = require('./theme'); const { runCockpitAction } = require('./action-runner'); const { PANE_MENU_ITEMS, @@ -435,10 +437,6 @@ function applyCockpitAction(state, action = {}) { return current; } -function stripAnsi(value) { - return String(value || '').replace(/\x1b\[[0-9;]*m/g, ''); -} - function splitLines(value) { return String(value || '').replace(/\n$/, '').split('\n'); } @@ -509,7 +507,7 @@ function renderDetailsPanel(state) { lines.push(`locks: ${Number.isFinite(session.lockCount) ? session.lockCount : 0}`); } - lines.push('', 'keys: up/down select m/Alt+Shift+M menu v/h/x/p/r/c/o/a/b/f/T/A pane actions s settings q quit'); + lines.push('', CONTROL_KEY_HELP); if (current.error) { lines.push('', `error: ${text(current.error)}`); } @@ -521,13 +519,14 @@ function renderDetailsPanel(state) { function renderMenuPanel(state) { const current = normalizeControlState(state); - return renderPaneMenu(paneMenuStateFromControl(current), { width: 72 }); + return renderPaneMenu(paneMenuStateFromControl(current), { width: 72, theme: current.settings.theme }); } function renderSettingsPanel(state) { const current = normalizeControlState(state); return renderSettingsScreen(current.settings, { selectedField: selectedField(current), + theme: current.settings.theme, }); } @@ -541,7 +540,7 @@ function renderPanel(state) { function renderControlFrame(state) { const current = normalizeControlState(state); const width = number(current.settings.sidebarWidth, DEFAULT_SETTINGS.sidebarWidth); - const sidebar = splitLines(renderSidebar(current, { width, noColor: true })); + const sidebar = splitLines(renderSidebar(current, { width, theme: current.settings.theme })); const framePanelState = current.mode === 'menu' ? normalizeControlState({ ...current, mode: 'details' }) : current; diff --git a/src/cockpit/menu.js b/src/cockpit/menu.js index ccb3e32..6261eee 100644 --- a/src/cockpit/menu.js +++ b/src/cockpit/menu.js @@ -1,6 +1,7 @@ 'use strict'; const paneMenu = require('./pane-menu'); +const { colorize, getCockpitTheme, stripAnsi } = require('./theme'); const { PANE_MENU_ACTIONS, @@ -141,11 +142,44 @@ function applyPaneMenuKey(state = {}, rawKey) { return paneMenu.applyPaneMenuKey(createPaneMenuState(state), rawKey); } +function themeMenuLine(line, state, theme) { + const plain = stripAnsi(line); + if (/^[┌├└+]/.test(plain)) { + return colorize(line, 'border', theme); + } + if (plain.includes('Menu:')) { + return colorize(line, 'title', theme); + } + if (plain.includes('status:')) { + return colorize(line, 'warning', theme); + } + if (plain.includes('Close')) { + return colorize(line, plain.includes('>') ? 'selected' : 'danger', theme); + } + if (plain.includes('>')) { + return colorize(line, 'selected', theme); + } + if (plain.includes(PANE_MENU_FOOTER)) { + return colorize(line, 'secondary', theme); + } + return line; +} + +function applyMenuTheme(output, state, options) { + const theme = getCockpitTheme(options.theme || state.theme || (state.settings && state.settings.theme), options); + if (!theme.color) { + return output; + } + return `${String(output).replace(/\n$/, '').split('\n').map((line) => themeMenuLine(line, state, theme)).join('\n')}\n`; +} + function renderPaneMenu(state = {}, options = {}) { const selectedIndex = Number.isInteger(options.selectedIndex) ? options.selectedIndex : state.selectedIndex; - return paneMenu.renderPaneMenu(createPaneMenuState({ ...state, selectedIndex }), options).replace(/\u25b6/g, '>'); + const current = createPaneMenuState({ ...state, selectedIndex }); + const output = paneMenu.renderPaneMenu(current, options).replace(/\u25b6/g, '>'); + return applyMenuTheme(output, current, options); } function buildLaneMenu(session, context = {}) { diff --git a/src/cockpit/settings-render.js b/src/cockpit/settings-render.js index 50fa8f9..c3009ed 100644 --- a/src/cockpit/settings-render.js +++ b/src/cockpit/settings-render.js @@ -1,7 +1,12 @@ 'use strict'; +const { SETTINGS_KEYBINDINGS } = require('./shortcuts'); +const { colorize, getCockpitTheme } = require('./theme'); + +const AVAILABLE_THEMES = 'blue, amber, dim, high-contrast, none'; + const DEFAULT_SETTINGS = { - theme: 'default', + theme: 'blue', sidebarWidth: 32, refreshMs: 2000, defaultAgent: 'codex', @@ -16,7 +21,7 @@ const SECTION_DEFINITIONS = [ { title: 'Appearance', fields: [ - ['theme', 'Theme', 'default, dim, high-contrast'], + ['theme', 'Theme', AVAILABLE_THEMES], ], }, { @@ -49,13 +54,6 @@ const SECTION_DEFINITIONS = [ }, ]; -const KEYBINDINGS = [ - '↑/↓ navigate', - 'Enter edit', - 'Esc back', - 'q quit', -]; - function normalizeSettings(settings) { if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { return { ...DEFAULT_SETTINGS }; @@ -77,9 +75,10 @@ function formatValue(value) { return String(value); } -function fieldLine(field, label, available, settings, selectedField) { +function fieldLine(field, label, available, settings, selectedField, theme) { const marker = field === selectedField ? '>' : ' '; - return `${marker} ${label}: ${formatValue(settings[field])} (available: ${available})`; + const line = `${marker} ${label}: ${formatValue(settings[field])} (available: ${available})`; + return field === selectedField ? colorize(line, 'selected', theme) : line; } function resolveSelectedField(options) { @@ -93,31 +92,32 @@ function resolveSelectedField(options) { return null; } -function renderSection(section, settings, selectedField) { - const lines = [`[${section.title}]`]; +function renderSection(section, settings, selectedField, theme) { + const lines = [colorize(`[${section.title}]`, 'heading', theme)]; for (const [field, label, available] of section.fields) { - lines.push(fieldLine(field, label, available, settings, selectedField)); + lines.push(fieldLine(field, label, available, settings, selectedField, theme)); } return lines.join('\n'); } function renderSettingsScreen(settings, options = {}) { const current = normalizeSettings(settings); + const theme = getCockpitTheme(options.theme || current.theme, options); const selectedField = resolveSelectedField(options); const lines = [ - 'gx cockpit settings', - 'Plain terminal settings view', + colorize('gx cockpit settings', 'title', theme), + colorize('Plain terminal settings view', 'secondary', theme), '', ]; for (const section of SECTION_DEFINITIONS) { - lines.push(renderSection(section, current, selectedField)); + lines.push(renderSection(section, current, selectedField, theme)); lines.push(''); } - lines.push('[Keybindings]'); - for (const keybinding of KEYBINDINGS) { - lines.push(` ${keybinding}`); + lines.push(colorize('[Keybindings]', 'heading', theme)); + for (const keybinding of SETTINGS_KEYBINDINGS) { + lines.push(colorize(` ${keybinding}`, 'secondary', theme)); } return `${lines.join('\n')}\n`; diff --git a/src/cockpit/shortcuts.js b/src/cockpit/shortcuts.js new file mode 100644 index 0000000..bdd58c2 --- /dev/null +++ b/src/cockpit/shortcuts.js @@ -0,0 +1,24 @@ +'use strict'; + +const WELCOME_SHORTCUTS = Object.freeze([ + ['n', 'new agent'], + ['t', 'terminal'], + ['s', 'settings'], + ['?', 'shortcuts'], + ['q', 'quit'], +]); + +const SETTINGS_KEYBINDINGS = Object.freeze([ + '↑/↓ navigate', + 'Enter edit', + 'Esc back', + 'q quit', +]); + +const CONTROL_KEY_HELP = 'keys: up/down select m/Alt+Shift+M menu v/h/x/p/r/c/o/a/b/f/T/A pane actions s settings q quit'; + +module.exports = { + CONTROL_KEY_HELP, + SETTINGS_KEYBINDINGS, + WELCOME_SHORTCUTS, +}; diff --git a/src/cockpit/sidebar.js b/src/cockpit/sidebar.js index 2cc6f17..c4e3b11 100644 --- a/src/cockpit/sidebar.js +++ b/src/cockpit/sidebar.js @@ -1,4 +1,5 @@ const path = require('node:path'); +const { colorize, getCockpitTheme } = require('./theme'); const DEFAULT_WIDTH = 36; const MIN_WIDTH = 12; @@ -22,16 +23,6 @@ const STATUS_STATES = new Map([ ['dead', 'stalled'], ]); -const ANSI = { - reset: '\x1b[0m', - dim: '\x1b[2m', - green: '\x1b[32m', - red: '\x1b[31m', - yellow: '\x1b[33m', - cyan: '\x1b[36m', - inverse: '\x1b[7m', -}; - const AGENT_LABELS = new Map([ ['codex', 'cx'], ['claude', 'cc'], @@ -166,33 +157,20 @@ function isSelected(session, index, state = {}, options = {}) { return Number.isInteger(selectedIndex) && selectedIndex === index; } -function colorEnabled(options = {}) { - const env = options.env && typeof options.env === 'object' ? options.env : process.env; - return options.color === true && !options.noColor && !env.NO_COLOR; -} - -function colorize(value, color, options = {}) { - if (!colorEnabled(options)) { - return value; - } - const code = ANSI[color]; - return code ? `${code}${value}${ANSI.reset}` : value; -} - -function statusColor(status) { +function statusToken(status) { if (status === 'active' || status === 'done') { - return 'green'; + return 'success'; } if (status === 'waiting') { - return 'yellow'; + return 'warning'; } if (status === 'blocked' || status === 'failed' || status === 'stalled' || status === 'missing') { - return 'red'; + return status === 'stalled' ? 'warning' : 'danger'; } if (status === 'hidden' || status === 'closed') { - return 'dim'; + return 'secondary'; } - return 'cyan'; + return 'accent'; } function laneName(session = {}) { @@ -222,15 +200,17 @@ function fitRow(left, right, width) { } function renderShortcutRows(width, options) { + const theme = getCockpitTheme(options.theme, options); const rows = [ ' [n]ew agent [t]erminal', ' [s]ettings [?] shortcuts', ]; - return rows.map((row) => colorize(boundLine(row, width), 'dim', options)); + return rows.map((row) => colorize(boundLine(row, width), 'secondary', theme)); } function renderSessionRow(session, index, state, options) { const width = sidebarWidth(options); + const theme = getCockpitTheme(options.theme || state.theme || (state.settings && state.settings.theme), options); const selected = isSelected(session, index, state, options); const marker = selected ? '>' : ' '; const status = laneState(session); @@ -238,19 +218,20 @@ function renderSessionRow(session, index, state, options) { const row = fitRow(`${marker} ${laneName(session)}`, ` ${badge}`, width); return selected - ? colorize(row, 'inverse', options) - : colorize(row, statusColor(status), options); + ? colorize(row, 'selected', theme) + : colorize(row, statusToken(status), theme); } function renderSidebar(state = {}, options = {}) { const width = sidebarWidth(options); + const theme = getCockpitTheme(options.theme || state.theme || (state.settings && state.settings.theme), 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 = [ - colorize(boundLine(title, width), 'cyan', options), - colorize(boundLine(repoName(state, options), width), 'dim', options), + colorize(boundLine(title, width), 'title', theme), + colorize(boundLine(repoName(state, options), width), 'secondary', theme), ]; if (sessions.length === 0) { diff --git a/src/cockpit/theme.js b/src/cockpit/theme.js new file mode 100644 index 0000000..1ae5444 --- /dev/null +++ b/src/cockpit/theme.js @@ -0,0 +1,128 @@ +'use strict'; + +const RESET = '\x1b[0m'; +const ANSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g; + +const THEME_NAMES = Object.freeze(['blue', 'amber', 'dim', 'high-contrast', 'none']); + +const PALETTES = Object.freeze({ + blue: { + accent: '\x1b[36m', + accentStrong: '\x1b[1;36m', + border: '\x1b[34m', + danger: '\x1b[31m', + secondary: '\x1b[2;90m', + selected: '\x1b[7;36m', + success: '\x1b[32m', + warning: '\x1b[33m', + }, + amber: { + accent: '\x1b[33m', + accentStrong: '\x1b[1;33m', + border: '\x1b[33m', + danger: '\x1b[31m', + secondary: '\x1b[2;90m', + selected: '\x1b[7;33m', + success: '\x1b[32m', + warning: '\x1b[93m', + }, + dim: { + accent: '\x1b[2;36m', + accentStrong: '\x1b[36m', + border: '\x1b[2;37m', + danger: '\x1b[2;31m', + secondary: '\x1b[2;90m', + selected: '\x1b[7;2m', + success: '\x1b[2;32m', + warning: '\x1b[2;33m', + }, + 'high-contrast': { + accent: '\x1b[1;37m', + accentStrong: '\x1b[1;97m', + border: '\x1b[1;37m', + danger: '\x1b[1;31m', + secondary: '\x1b[37m', + selected: '\x1b[7;1m', + success: '\x1b[1;32m', + warning: '\x1b[1;33m', + }, + none: {}, +}); + +const TOKEN_ALIASES = Object.freeze({ + active: 'success', + complete: 'success', + completed: 'success', + done: 'success', + error: 'danger', + failed: 'danger', + heading: 'accentStrong', + hidden: 'secondary', + idle: 'secondary', + info: 'accent', + missing: 'danger', + muted: 'secondary', + primary: 'accent', + stalled: 'warning', + title: 'accentStrong', + waiting: 'warning', +}); + +function stripAnsi(text) { + return String(text || '').replace(ANSI_PATTERN, ''); +} + +function hasNoColor(options = {}) { + if (options.noColor === true || options.color === false) { + return true; + } + + const env = options.env && typeof options.env === 'object' ? options.env : process.env; + if (Object.prototype.hasOwnProperty.call(env, 'NO_COLOR')) { + return true; + } + + const argv = Array.isArray(options.argv) ? options.argv : process.argv; + return argv.includes('--no-color'); +} + +function normalizeThemeName(name) { + const requested = typeof name === 'string' ? name.trim().toLowerCase() : ''; + if (requested === '' || requested === 'default') { + return 'blue'; + } + return Object.prototype.hasOwnProperty.call(PALETTES, requested) ? requested : 'blue'; +} + +function getCockpitTheme(name = 'blue', options = {}) { + const themeName = normalizeThemeName(name); + const noColor = themeName === 'none' || hasNoColor(options); + return { + name: themeName, + color: !noColor, + reset: RESET, + tokens: noColor ? PALETTES.none : PALETTES[themeName], + available: THEME_NAMES, + }; +} + +function colorize(text, token, theme) { + const value = String(text || ''); + const current = theme && typeof theme === 'object' && theme.tokens + ? theme + : getCockpitTheme(theme); + + if (!current.color) { + return value; + } + + const resolvedToken = TOKEN_ALIASES[token] || token; + const code = current.tokens[resolvedToken]; + return code ? `${code}${value}${RESET}` : value; +} + +module.exports = { + getCockpitTheme, + colorize, + stripAnsi, +}; diff --git a/src/cockpit/welcome.js b/src/cockpit/welcome.js index 3a7f58a..66c4ba5 100644 --- a/src/cockpit/welcome.js +++ b/src/cockpit/welcome.js @@ -1,20 +1,14 @@ 'use strict'; const path = require('node:path'); +const { WELCOME_SHORTCUTS } = require('./shortcuts'); +const { colorize, getCockpitTheme } = require('./theme'); const DEFAULT_WIDTH = 76; const MIN_WIDTH = 48; const MAX_WIDTH = 88; const DEFAULT_AGENTS = ['codex', 'claude', 'opencode', 'cursor', 'gemini']; -const SHORTCUTS = [ - ['n', 'new agent'], - ['t', 'terminal'], - ['s', 'settings'], - ['?', 'shortcuts'], - ['q', 'quit'], -]; - const GUARD_MOTIF = [ ' __', ' / _)', @@ -213,43 +207,48 @@ function emptyLine(width) { return boxedLine('', width); } +function themedBoxedLine(value, width, token, theme) { + return colorize(boxedLine(value, width), token, theme); +} + function renderWelcomePage(state = {}, settings = {}) { const width = boundedWidth(settings); + const theme = getCockpitTheme(settings.theme || state.theme, settings); const hooks = hooksStatus(state); const lines = [ - divider(width), - boxedLine('gitguardex | gx cockpit', width), - boxedLine('Guardian cockpit ready. No active agent lanes.', width), + colorize(divider(width), 'border', theme), + themedBoxedLine('gitguardex | gx cockpit', width, 'title', theme), + themedBoxedLine('Guardian cockpit ready. No active agent lanes.', width, 'secondary', theme), emptyLine(width), ]; GUARD_MOTIF.forEach((motifLine) => { - lines.push(boxedLine(motifLine, width)); + lines.push(themedBoxedLine(motifLine, width, 'accent', theme)); }); lines.push( emptyLine(width), - boxedLine(row('Repo:', repoName(state, settings)), width), - boxedLine(row('Branch:', `${currentBranch(state)} (base ${baseBranch(state, settings)})`), width), - boxedLine(row('Safety:', safetyStatus(state)), width), + themedBoxedLine(row('Repo:', repoName(state, settings)), width, 'secondary', theme), + themedBoxedLine(row('Branch:', `${currentBranch(state)} (base ${baseBranch(state, settings)})`), width, 'secondary', theme), + themedBoxedLine(row('Safety:', safetyStatus(state)), width, 'success', theme), ); if (hooks) { - lines.push(boxedLine(row('Hooks:', hooks), width)); + lines.push(themedBoxedLine(row('Hooks:', hooks), width, 'secondary', theme)); } lines.push( - boxedLine(row('Locks:', String(totalLockCount(state))), width), - boxedLine(row('Agents:', availableAgents(state, settings)), width), + themedBoxedLine(row('Locks:', String(totalLockCount(state))), width, 'accent', theme), + themedBoxedLine(row('Agents:', availableAgents(state, settings)), width, 'secondary', theme), emptyLine(width), - boxedLine('Shortcuts', width), - ...SHORTCUTS.map(([key, label]) => boxedLine(` ${key} ${label}`, width)), + themedBoxedLine('Shortcuts', width, 'heading', theme), + ...WELCOME_SHORTCUTS.map(([key, label]) => themedBoxedLine(` ${key} ${label}`, width, 'secondary', theme)), emptyLine(width), - boxedLine('Next actions', width), - boxedLine(' n new agent - start a guarded agent lane', width), - boxedLine(' t terminal - open a repo terminal', width), - boxedLine(' s settings - tune cockpit defaults', width), - divider(width), + themedBoxedLine('Next actions', width, 'heading', theme), + themedBoxedLine(' n new agent - start a guarded agent lane', width, 'secondary', theme), + themedBoxedLine(' t terminal - open a repo terminal', width, 'secondary', theme), + themedBoxedLine(' s settings - tune cockpit defaults', width, 'secondary', theme), + colorize(divider(width), 'border', theme), ); return `${lines.join('\n')}\n`; diff --git a/test/cockpit-settings-render.test.js b/test/cockpit-settings-render.test.js index 1c8ba96..b5cdc50 100644 --- a/test/cockpit-settings-render.test.js +++ b/test/cockpit-settings-render.test.js @@ -20,7 +20,7 @@ test('renderSettingsScreen shows settings sections and current values', () => { assert.match(output, /gx cockpit settings/); assert.match(output, /\[Appearance\]/); - assert.match(output, /Theme: dim \(available: default, dim, high-contrast\)/); + assert.match(output, /Theme: dim \(available: blue, amber, dim, high-contrast, none\)/); assert.match(output, /\[Layout\]/); assert.match(output, /Sidebar width: 44 \(available: 20-80 columns\)/); assert.match(output, /Refresh interval: 3000 \(available: 500-60000 ms\)/); @@ -48,7 +48,7 @@ test('renderSettingsScreen includes fixed keyboard hints', () => { test('renderSettingsScreen uses defaults and can mark the selected setting', () => { const output = renderSettingsScreen(null, { selectedField: 'theme' }); - assert.match(output, /> Theme: default \(available: default, dim, high-contrast\)/); + assert.match(output, /> Theme: blue \(available: blue, amber, dim, high-contrast, none\)/); assert.match(output, /Editor command: \(blank\) \(available: any shell command, blank\)/); assert.equal(output.endsWith('\n'), true); }); diff --git a/test/cockpit-theme.test.js b/test/cockpit-theme.test.js new file mode 100644 index 0000000..9239fac --- /dev/null +++ b/test/cockpit-theme.test.js @@ -0,0 +1,132 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const { renderLaneMenu, buildLaneMenu } = require('../src/cockpit/menu'); +const { renderSidebar } = require('../src/cockpit/sidebar'); +const { colorize, getCockpitTheme, stripAnsi } = require('../src/cockpit/theme'); +const { renderWelcomePage } = require('../src/cockpit/welcome'); +const { renderControlFrame, applyCockpitAction } = require('../src/cockpit/control'); + +test('getCockpitTheme resolves supported themes and default blue alias', () => { + assert.equal(getCockpitTheme('blue').name, 'blue'); + assert.equal(getCockpitTheme('amber').name, 'amber'); + assert.equal(getCockpitTheme('dim').name, 'dim'); + assert.equal(getCockpitTheme('high-contrast').name, 'high-contrast'); + assert.equal(getCockpitTheme('none').name, 'none'); + assert.equal(getCockpitTheme('default').name, 'blue'); + assert.equal(getCockpitTheme('missing').name, 'blue'); + + const blue = getCockpitTheme('blue', { env: {} }); + assert.equal(blue.tokens.accent, '\x1b[36m'); + assert.equal(blue.tokens.success, '\x1b[32m'); + assert.equal(blue.tokens.warning, '\x1b[33m'); + assert.equal(blue.tokens.danger, '\x1b[31m'); + assert.equal(blue.tokens.secondary, '\x1b[2;90m'); +}); + +test('colorize applies tokens and stripAnsi removes color', () => { + const output = colorize('guarded', 'success', getCockpitTheme('blue', { env: {} })); + + assert.match(output, /^\x1b\[32mguarded\x1b\[0m$/); + assert.equal(stripAnsi(output), 'guarded'); +}); + +test('NO_COLOR and --no-color disable theme color output', () => { + const noColorTheme = getCockpitTheme('blue', { env: { NO_COLOR: '' } }); + const argvTheme = getCockpitTheme('blue', { env: {}, argv: ['node', 'gx', '--no-color'] }); + + assert.equal(noColorTheme.color, false); + assert.equal(argvTheme.color, false); + assert.equal(colorize('plain', 'accent', noColorTheme), 'plain'); + assert.equal(colorize('plain', 'accent', argvTheme), 'plain'); +}); + +test('sidebar stays readable with no color', () => { + const output = renderSidebar({ + repoPath: '/work/gitguardex', + selectedSessionId: 's1', + settings: { theme: 'blue' }, + sessions: [{ + id: 's1', + agentName: 'codex', + branch: 'agent/codex/theme', + task: 'ship blue guard terminal theme', + status: 'working', + lockCount: 2, + worktreeExists: true, + }], + }, { noColor: true, width: 38 }); + + assert.equal(stripAnsi(output), output); + assert.match(output, /gx cockpit/); + assert.match(output, /> ship blue guard ter\.\.\. \[cx\] \(active\)/); +}); + +test('menu stays readable with no color and monochrome box drawing', () => { + const menu = buildLaneMenu({ + agent: 'codex', + branch: 'agent/codex/theme', + worktreePath: '/repo/.omx/agent-worktrees/theme', + worktreeExists: true, + }); + const output = renderLaneMenu(menu, { ascii: true, noColor: true }); + + assert.equal(stripAnsi(output), output); + assert.match(output, /^\+/); + assert.match(output, /\| Menu: codex/); + assert.match(output, /Close\s+\[x\]/); + assert.match(output, /Create GitHub PR/); +}); + +test('welcome stays readable with no color', () => { + const output = renderWelcomePage({ + repoPath: '/work/gitguardex', + currentBranch: 'agent/codex/theme', + baseBranch: 'main', + safetyStatus: 'guarded', + lockCount: 1, + }, { noColor: true, width: 60, theme: 'blue' }); + + assert.equal(stripAnsi(output), output); + assert.match(output, /^\+/); + assert.match(output, /gitguardex \| gx cockpit/); + assert.match(output, /Safety:\s+guarded/); + assert.match(output, /n new agent/); +}); + +test('control frame respects no color environment', () => { + const state = applyCockpitAction({}, { + type: 'refresh', + cockpitState: { + repoPath: '/repo/gitguardex', + baseBranch: 'main', + sessions: [{ + id: 'theme', + agentName: 'codex', + branch: 'agent/codex/theme', + task: 'blue theme', + status: 'working', + worktreePath: '/tmp/theme', + worktreeExists: true, + }], + }, + settings: { theme: 'blue', sidebarWidth: 36 }, + }); + + const previous = process.env.NO_COLOR; + process.env.NO_COLOR = '1'; + try { + const output = renderControlFrame(state); + assert.equal(stripAnsi(output), output); + assert.match(output, /blue theme\s+\[cx\] \(active\)/); + assert.match(output, /keys: up\/down select/); + } finally { + if (previous === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = previous; + } + } +});