Skip to content

Commit 5364734

Browse files
NagyViktNagyViktOmX
authored
Give cockpit renders a guarded blue theme (#514)
The cockpit needed a GitGuardex-native palette instead of keeping the dmux amber assumptions in each renderer. This adds a small local theme helper and routes sidebar, menu, welcome, settings, and control rendering through shared color tokens while keeping no-color output plain. Constraint: Respect NO_COLOR and --no-color without adding color dependencies Constraint: Keep box drawing and terminal text readable after ANSI stripping Rejected: Add a chalk-style dependency | local ANSI tokens are enough for the narrow cockpit surface Confidence: medium Scope-risk: moderate Directive: Keep cockpit color tokens centralized in src/cockpit/theme.js before adding renderer-local ANSI codes Tested: node --test test/cockpit-theme.test.js test/cockpit-sidebar.test.js test/cockpit-menu.test.js test/cockpit-settings-render.test.js test/cockpit-welcome.test.js Tested: node --test test/cockpit-settings.test.js test/cockpit-theme.test.js test/cockpit-settings-render.test.js Tested: git diff --check Not-tested: Full test/cockpit-control.test.js is red on unmodified main for pre-existing shortcut-mode assertions Co-authored-by: NagyVikt <nagy.viktordp@gmail.com> Co-authored-by: OmX <omx@oh-my-codex.dev>
1 parent e868558 commit 5364734

9 files changed

Lines changed: 386 additions & 89 deletions

File tree

src/cockpit/control.js

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
const { readCockpitState } = require('./state');
44
const { renderSidebar } = require('./sidebar');
55
const { renderSettingsScreen } = require('./settings-render');
6+
const { CONTROL_KEY_HELP } = require('./shortcuts');
7+
const { stripAnsi } = require('./theme');
68
const { runCockpitAction } = require('./action-runner');
79
const {
810
PANE_MENU_ITEMS,
@@ -435,10 +437,6 @@ function applyCockpitAction(state, action = {}) {
435437
return current;
436438
}
437439

438-
function stripAnsi(value) {
439-
return String(value || '').replace(/\x1b\[[0-9;]*m/g, '');
440-
}
441-
442440
function splitLines(value) {
443441
return String(value || '').replace(/\n$/, '').split('\n');
444442
}
@@ -509,7 +507,7 @@ function renderDetailsPanel(state) {
509507
lines.push(`locks: ${Number.isFinite(session.lockCount) ? session.lockCount : 0}`);
510508
}
511509

512-
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');
510+
lines.push('', CONTROL_KEY_HELP);
513511
if (current.error) {
514512
lines.push('', `error: ${text(current.error)}`);
515513
}
@@ -521,13 +519,14 @@ function renderDetailsPanel(state) {
521519

522520
function renderMenuPanel(state) {
523521
const current = normalizeControlState(state);
524-
return renderPaneMenu(paneMenuStateFromControl(current), { width: 72 });
522+
return renderPaneMenu(paneMenuStateFromControl(current), { width: 72, theme: current.settings.theme });
525523
}
526524

527525
function renderSettingsPanel(state) {
528526
const current = normalizeControlState(state);
529527
return renderSettingsScreen(current.settings, {
530528
selectedField: selectedField(current),
529+
theme: current.settings.theme,
531530
});
532531
}
533532

@@ -541,7 +540,7 @@ function renderPanel(state) {
541540
function renderControlFrame(state) {
542541
const current = normalizeControlState(state);
543542
const width = number(current.settings.sidebarWidth, DEFAULT_SETTINGS.sidebarWidth);
544-
const sidebar = splitLines(renderSidebar(current, { width, noColor: true }));
543+
const sidebar = splitLines(renderSidebar(current, { width, theme: current.settings.theme }));
545544
const framePanelState = current.mode === 'menu'
546545
? normalizeControlState({ ...current, mode: 'details' })
547546
: current;

src/cockpit/menu.js

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict';
22

33
const paneMenu = require('./pane-menu');
4+
const { colorize, getCockpitTheme, stripAnsi } = require('./theme');
45

56
const {
67
PANE_MENU_ACTIONS,
@@ -141,11 +142,44 @@ function applyPaneMenuKey(state = {}, rawKey) {
141142
return paneMenu.applyPaneMenuKey(createPaneMenuState(state), rawKey);
142143
}
143144

145+
function themeMenuLine(line, state, theme) {
146+
const plain = stripAnsi(line);
147+
if (/^[+]/.test(plain)) {
148+
return colorize(line, 'border', theme);
149+
}
150+
if (plain.includes('Menu:')) {
151+
return colorize(line, 'title', theme);
152+
}
153+
if (plain.includes('status:')) {
154+
return colorize(line, 'warning', theme);
155+
}
156+
if (plain.includes('Close')) {
157+
return colorize(line, plain.includes('>') ? 'selected' : 'danger', theme);
158+
}
159+
if (plain.includes('>')) {
160+
return colorize(line, 'selected', theme);
161+
}
162+
if (plain.includes(PANE_MENU_FOOTER)) {
163+
return colorize(line, 'secondary', theme);
164+
}
165+
return line;
166+
}
167+
168+
function applyMenuTheme(output, state, options) {
169+
const theme = getCockpitTheme(options.theme || state.theme || (state.settings && state.settings.theme), options);
170+
if (!theme.color) {
171+
return output;
172+
}
173+
return `${String(output).replace(/\n$/, '').split('\n').map((line) => themeMenuLine(line, state, theme)).join('\n')}\n`;
174+
}
175+
144176
function renderPaneMenu(state = {}, options = {}) {
145177
const selectedIndex = Number.isInteger(options.selectedIndex)
146178
? options.selectedIndex
147179
: state.selectedIndex;
148-
return paneMenu.renderPaneMenu(createPaneMenuState({ ...state, selectedIndex }), options).replace(/\u25b6/g, '>');
180+
const current = createPaneMenuState({ ...state, selectedIndex });
181+
const output = paneMenu.renderPaneMenu(current, options).replace(/\u25b6/g, '>');
182+
return applyMenuTheme(output, current, options);
149183
}
150184

151185
function buildLaneMenu(session, context = {}) {

src/cockpit/settings-render.js

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
'use strict';
22

3+
const { SETTINGS_KEYBINDINGS } = require('./shortcuts');
4+
const { colorize, getCockpitTheme } = require('./theme');
5+
6+
const AVAILABLE_THEMES = 'blue, amber, dim, high-contrast, none';
7+
38
const DEFAULT_SETTINGS = {
4-
theme: 'default',
9+
theme: 'blue',
510
sidebarWidth: 32,
611
refreshMs: 2000,
712
defaultAgent: 'codex',
@@ -16,7 +21,7 @@ const SECTION_DEFINITIONS = [
1621
{
1722
title: 'Appearance',
1823
fields: [
19-
['theme', 'Theme', 'default, dim, high-contrast'],
24+
['theme', 'Theme', AVAILABLE_THEMES],
2025
],
2126
},
2227
{
@@ -49,13 +54,6 @@ const SECTION_DEFINITIONS = [
4954
},
5055
];
5156

52-
const KEYBINDINGS = [
53-
'↑/↓ navigate',
54-
'Enter edit',
55-
'Esc back',
56-
'q quit',
57-
];
58-
5957
function normalizeSettings(settings) {
6058
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
6159
return { ...DEFAULT_SETTINGS };
@@ -77,9 +75,10 @@ function formatValue(value) {
7775
return String(value);
7876
}
7977

80-
function fieldLine(field, label, available, settings, selectedField) {
78+
function fieldLine(field, label, available, settings, selectedField, theme) {
8179
const marker = field === selectedField ? '>' : ' ';
82-
return `${marker} ${label}: ${formatValue(settings[field])} (available: ${available})`;
80+
const line = `${marker} ${label}: ${formatValue(settings[field])} (available: ${available})`;
81+
return field === selectedField ? colorize(line, 'selected', theme) : line;
8382
}
8483

8584
function resolveSelectedField(options) {
@@ -93,31 +92,32 @@ function resolveSelectedField(options) {
9392
return null;
9493
}
9594

96-
function renderSection(section, settings, selectedField) {
97-
const lines = [`[${section.title}]`];
95+
function renderSection(section, settings, selectedField, theme) {
96+
const lines = [colorize(`[${section.title}]`, 'heading', theme)];
9897
for (const [field, label, available] of section.fields) {
99-
lines.push(fieldLine(field, label, available, settings, selectedField));
98+
lines.push(fieldLine(field, label, available, settings, selectedField, theme));
10099
}
101100
return lines.join('\n');
102101
}
103102

104103
function renderSettingsScreen(settings, options = {}) {
105104
const current = normalizeSettings(settings);
105+
const theme = getCockpitTheme(options.theme || current.theme, options);
106106
const selectedField = resolveSelectedField(options);
107107
const lines = [
108-
'gx cockpit settings',
109-
'Plain terminal settings view',
108+
colorize('gx cockpit settings', 'title', theme),
109+
colorize('Plain terminal settings view', 'secondary', theme),
110110
'',
111111
];
112112

113113
for (const section of SECTION_DEFINITIONS) {
114-
lines.push(renderSection(section, current, selectedField));
114+
lines.push(renderSection(section, current, selectedField, theme));
115115
lines.push('');
116116
}
117117

118-
lines.push('[Keybindings]');
119-
for (const keybinding of KEYBINDINGS) {
120-
lines.push(` ${keybinding}`);
118+
lines.push(colorize('[Keybindings]', 'heading', theme));
119+
for (const keybinding of SETTINGS_KEYBINDINGS) {
120+
lines.push(colorize(` ${keybinding}`, 'secondary', theme));
121121
}
122122

123123
return `${lines.join('\n')}\n`;

src/cockpit/shortcuts.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
'use strict';
2+
3+
const WELCOME_SHORTCUTS = Object.freeze([
4+
['n', 'new agent'],
5+
['t', 'terminal'],
6+
['s', 'settings'],
7+
['?', 'shortcuts'],
8+
['q', 'quit'],
9+
]);
10+
11+
const SETTINGS_KEYBINDINGS = Object.freeze([
12+
'↑/↓ navigate',
13+
'Enter edit',
14+
'Esc back',
15+
'q quit',
16+
]);
17+
18+
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';
19+
20+
module.exports = {
21+
CONTROL_KEY_HELP,
22+
SETTINGS_KEYBINDINGS,
23+
WELCOME_SHORTCUTS,
24+
};

src/cockpit/sidebar.js

Lines changed: 15 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const path = require('node:path');
2+
const { colorize, getCockpitTheme } = require('./theme');
23

34
const DEFAULT_WIDTH = 36;
45
const MIN_WIDTH = 12;
@@ -22,16 +23,6 @@ const STATUS_STATES = new Map([
2223
['dead', 'stalled'],
2324
]);
2425

25-
const ANSI = {
26-
reset: '\x1b[0m',
27-
dim: '\x1b[2m',
28-
green: '\x1b[32m',
29-
red: '\x1b[31m',
30-
yellow: '\x1b[33m',
31-
cyan: '\x1b[36m',
32-
inverse: '\x1b[7m',
33-
};
34-
3526
const AGENT_LABELS = new Map([
3627
['codex', 'cx'],
3728
['claude', 'cc'],
@@ -166,33 +157,20 @@ function isSelected(session, index, state = {}, options = {}) {
166157
return Number.isInteger(selectedIndex) && selectedIndex === index;
167158
}
168159

169-
function colorEnabled(options = {}) {
170-
const env = options.env && typeof options.env === 'object' ? options.env : process.env;
171-
return options.color === true && !options.noColor && !env.NO_COLOR;
172-
}
173-
174-
function colorize(value, color, options = {}) {
175-
if (!colorEnabled(options)) {
176-
return value;
177-
}
178-
const code = ANSI[color];
179-
return code ? `${code}${value}${ANSI.reset}` : value;
180-
}
181-
182-
function statusColor(status) {
160+
function statusToken(status) {
183161
if (status === 'active' || status === 'done') {
184-
return 'green';
162+
return 'success';
185163
}
186164
if (status === 'waiting') {
187-
return 'yellow';
165+
return 'warning';
188166
}
189167
if (status === 'blocked' || status === 'failed' || status === 'stalled' || status === 'missing') {
190-
return 'red';
168+
return status === 'stalled' ? 'warning' : 'danger';
191169
}
192170
if (status === 'hidden' || status === 'closed') {
193-
return 'dim';
171+
return 'secondary';
194172
}
195-
return 'cyan';
173+
return 'accent';
196174
}
197175

198176
function laneName(session = {}) {
@@ -222,35 +200,38 @@ function fitRow(left, right, width) {
222200
}
223201

224202
function renderShortcutRows(width, options) {
203+
const theme = getCockpitTheme(options.theme, options);
225204
const rows = [
226205
' [n]ew agent [t]erminal',
227206
' [s]ettings [?] shortcuts',
228207
];
229-
return rows.map((row) => colorize(boundLine(row, width), 'dim', options));
208+
return rows.map((row) => colorize(boundLine(row, width), 'secondary', theme));
230209
}
231210

232211
function renderSessionRow(session, index, state, options) {
233212
const width = sidebarWidth(options);
213+
const theme = getCockpitTheme(options.theme || state.theme || (state.settings && state.settings.theme), options);
234214
const selected = isSelected(session, index, state, options);
235215
const marker = selected ? '>' : ' ';
236216
const status = laneState(session);
237217
const badge = `[${agentLabel(session.agentName || session.agent || session.owner)}] (${status})`;
238218
const row = fitRow(`${marker} ${laneName(session)}`, ` ${badge}`, width);
239219

240220
return selected
241-
? colorize(row, 'inverse', options)
242-
: colorize(row, statusColor(status), options);
221+
? colorize(row, 'selected', theme)
222+
: colorize(row, statusToken(status), theme);
243223
}
244224

245225
function renderSidebar(state = {}, options = {}) {
246226
const width = sidebarWidth(options);
227+
const theme = getCockpitTheme(options.theme || state.theme || (state.settings && state.settings.theme), options);
247228
const title = text(options.title || state.title, 'gx cockpit').toLowerCase() === 'gitguardex'
248229
? 'gitguardex'
249230
: text(options.title || state.title, 'gx cockpit');
250231
const sessions = Array.isArray(state.sessions) ? state.sessions : [];
251232
const lines = [
252-
colorize(boundLine(title, width), 'cyan', options),
253-
colorize(boundLine(repoName(state, options), width), 'dim', options),
233+
colorize(boundLine(title, width), 'title', theme),
234+
colorize(boundLine(repoName(state, options), width), 'secondary', theme),
254235
];
255236

256237
if (sessions.length === 0) {

0 commit comments

Comments
 (0)