Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions src/cockpit/control.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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');
}
Expand Down Expand Up @@ -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)}`);
}
Expand All @@ -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,
});
}

Expand All @@ -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;
Expand Down
36 changes: 35 additions & 1 deletion src/cockpit/menu.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

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

const {
PANE_MENU_ACTIONS,
Expand Down Expand Up @@ -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 = {}) {
Expand Down
40 changes: 20 additions & 20 deletions src/cockpit/settings-render.js
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -16,7 +21,7 @@ const SECTION_DEFINITIONS = [
{
title: 'Appearance',
fields: [
['theme', 'Theme', 'default, dim, high-contrast'],
['theme', 'Theme', AVAILABLE_THEMES],
],
},
{
Expand Down Expand Up @@ -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 };
Expand All @@ -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) {
Expand All @@ -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`;
Expand Down
24 changes: 24 additions & 0 deletions src/cockpit/shortcuts.js
Original file line number Diff line number Diff line change
@@ -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,
};
49 changes: 15 additions & 34 deletions src/cockpit/sidebar.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const path = require('node:path');
const { colorize, getCockpitTheme } = require('./theme');

const DEFAULT_WIDTH = 36;
const MIN_WIDTH = 12;
Expand All @@ -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'],
Expand Down Expand Up @@ -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 = {}) {
Expand Down Expand Up @@ -222,35 +200,38 @@ 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);
const badge = `[${agentLabel(session.agentName || session.agent || session.owner)}] (${status})`;
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) {
Expand Down
Loading