Skip to content

Commit f6a1bd5

Browse files
NagyViktNagyViktOmX
authored
Make empty cockpit state actionable (#508)
Add a standalone cockpit welcome renderer so an empty lane list can present repo safety context, available agents, shortcuts, and next actions in a bounded plain-terminal layout. Constraint: User requested edits only to src/cockpit/welcome.js and test/cockpit-welcome.test.js Rejected: Wire the renderer into src/cockpit/render.js | outside the requested edit scope Confidence: high Scope-risk: narrow Tested: node --check src/cockpit/welcome.js Tested: node --test test/cockpit-welcome.test.js test/cockpit-render.test.js test/cockpit-settings-render.test.js Co-authored-by: NagyVikt <nagy.viktordp@gmail.com> Co-authored-by: OmX <omx@oh-my-codex.dev>
1 parent 98ed9c5 commit f6a1bd5

2 files changed

Lines changed: 332 additions & 0 deletions

File tree

src/cockpit/welcome.js

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
'use strict';
2+
3+
const path = require('node:path');
4+
5+
const DEFAULT_WIDTH = 76;
6+
const MIN_WIDTH = 48;
7+
const MAX_WIDTH = 88;
8+
9+
const DEFAULT_AGENTS = ['codex', 'claude', 'opencode', 'cursor', 'gemini'];
10+
const SHORTCUTS = [
11+
['n', 'new agent'],
12+
['t', 'terminal'],
13+
['s', 'settings'],
14+
['?', 'shortcuts'],
15+
['q', 'quit'],
16+
];
17+
18+
const GUARD_MOTIF = [
19+
' __',
20+
' / _)',
21+
' .-^^^-/',
22+
'/ gx \\',
23+
'|_|--|_|',
24+
];
25+
26+
function stringValue(value, fallback = '') {
27+
if (typeof value === 'string') {
28+
return value.trim() || fallback;
29+
}
30+
if (value === null || value === undefined) {
31+
return fallback;
32+
}
33+
return String(value).trim() || fallback;
34+
}
35+
36+
function firstString(...values) {
37+
for (const value of values) {
38+
const text = stringValue(value);
39+
if (text) {
40+
return text;
41+
}
42+
}
43+
return '';
44+
}
45+
46+
function boundedWidth(settings = {}) {
47+
const width = Number(settings.width || settings.welcomeWidth || settings.cockpitWidth);
48+
if (!Number.isFinite(width)) {
49+
return DEFAULT_WIDTH;
50+
}
51+
return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, Math.floor(width)));
52+
}
53+
54+
function truncate(value, width) {
55+
const text = stringValue(value);
56+
if (width <= 0) {
57+
return '';
58+
}
59+
if (text.length <= width) {
60+
return text;
61+
}
62+
if (width <= 3) {
63+
return text.slice(0, width);
64+
}
65+
return `${text.slice(0, width - 3)}...`;
66+
}
67+
68+
function repoName(state = {}, settings = {}) {
69+
const explicit = firstString(
70+
settings.repoName,
71+
state.repoName,
72+
state.projectName,
73+
state.repo,
74+
state.name,
75+
);
76+
if (explicit) {
77+
return explicit;
78+
}
79+
80+
const repoPath = firstString(state.repoPath, state.repoRoot, state.agentsStatus && state.agentsStatus.repoRoot);
81+
if (!repoPath) {
82+
return '-';
83+
}
84+
return path.basename(repoPath) || repoPath;
85+
}
86+
87+
function currentBranch(state = {}) {
88+
return firstString(
89+
state.currentBranch,
90+
state.branch,
91+
state.git && state.git.currentBranch,
92+
state.agentsStatus && state.agentsStatus.currentBranch,
93+
) || '-';
94+
}
95+
96+
function baseBranch(state = {}, settings = {}) {
97+
return firstString(
98+
state.baseBranch,
99+
state.base,
100+
settings.baseBranch,
101+
settings.defaultBase,
102+
state.git && state.git.baseBranch,
103+
state.agentsStatus && state.agentsStatus.baseBranch,
104+
) || '-';
105+
}
106+
107+
function hooksStatus(state = {}) {
108+
const hooks = state.hooks || state.gitHooks || state.safetyHooks;
109+
const direct = firstString(
110+
state.hooksStatus,
111+
state.hookStatus,
112+
state.coreHooksPath,
113+
state.safety && state.safety.hooksStatus,
114+
);
115+
if (direct) {
116+
return direct;
117+
}
118+
if (typeof hooks === 'boolean') {
119+
return hooks ? 'enabled' : 'disabled';
120+
}
121+
if (typeof hooks === 'string') {
122+
return hooks.trim();
123+
}
124+
if (hooks && typeof hooks === 'object') {
125+
return firstString(hooks.status, hooks.state, hooks.coreHooksPath, hooks.path, hooks.value);
126+
}
127+
return '';
128+
}
129+
130+
function safetyStatus(state = {}) {
131+
return firstString(
132+
state.safetyStatus,
133+
state.guardStatus,
134+
state.guardexStatus,
135+
state.safety && state.safety.status,
136+
state.agentsStatus && state.agentsStatus.safetyStatus,
137+
) || 'unknown';
138+
}
139+
140+
function normalizeAgentList(value) {
141+
if (typeof value === 'string') {
142+
return value.split(',').map((item) => item.trim()).filter(Boolean);
143+
}
144+
if (!Array.isArray(value)) {
145+
return [];
146+
}
147+
return value
148+
.map((agent) => {
149+
if (typeof agent === 'string') {
150+
return agent.trim();
151+
}
152+
if (agent && typeof agent === 'object') {
153+
return firstString(agent.name, agent.agent, agent.id, agent.label);
154+
}
155+
return '';
156+
})
157+
.filter(Boolean);
158+
}
159+
160+
function availableAgents(state = {}, settings = {}) {
161+
const agents = [
162+
...normalizeAgentList(settings.availableAgents),
163+
...normalizeAgentList(settings.agents),
164+
...normalizeAgentList(state.availableAgents),
165+
...normalizeAgentList(state.agents),
166+
];
167+
168+
const source = agents.length > 0 ? agents : DEFAULT_AGENTS;
169+
return Array.from(new Set(source)).join(', ');
170+
}
171+
172+
function totalLockCount(state = {}) {
173+
if (Number.isFinite(state.lockCount)) {
174+
return Math.max(0, Math.floor(state.lockCount));
175+
}
176+
if (Array.isArray(state.locks)) {
177+
return state.locks.length;
178+
}
179+
if (state.lockSummary && Number.isFinite(state.lockSummary.count)) {
180+
return Math.max(0, Math.floor(state.lockSummary.count));
181+
}
182+
if (state.agentsStatus && Number.isFinite(state.agentsStatus.lockCount)) {
183+
return Math.max(0, Math.floor(state.agentsStatus.lockCount));
184+
}
185+
186+
const sessions = Array.isArray(state.sessions) ? state.sessions : [];
187+
return sessions.reduce((count, session) => {
188+
if (Array.isArray(session.locks)) {
189+
return count + session.locks.length;
190+
}
191+
if (Number.isFinite(session.lockCount)) {
192+
return count + Math.max(0, Math.floor(session.lockCount));
193+
}
194+
return count;
195+
}, 0);
196+
}
197+
198+
function row(label, value) {
199+
return `${label.padEnd(12)} ${value}`;
200+
}
201+
202+
function boxedLine(value, width) {
203+
const innerWidth = width - 4;
204+
const text = truncate(value, innerWidth);
205+
return `| ${text.padEnd(innerWidth)} |`;
206+
}
207+
208+
function divider(width) {
209+
return `+${'-'.repeat(width - 2)}+`;
210+
}
211+
212+
function emptyLine(width) {
213+
return boxedLine('', width);
214+
}
215+
216+
function renderWelcomePage(state = {}, settings = {}) {
217+
const width = boundedWidth(settings);
218+
const hooks = hooksStatus(state);
219+
const lines = [
220+
divider(width),
221+
boxedLine('gitguardex | gx cockpit', width),
222+
boxedLine('Guardian cockpit ready. No active agent lanes.', width),
223+
emptyLine(width),
224+
];
225+
226+
GUARD_MOTIF.forEach((motifLine) => {
227+
lines.push(boxedLine(motifLine, width));
228+
});
229+
230+
lines.push(
231+
emptyLine(width),
232+
boxedLine(row('Repo:', repoName(state, settings)), width),
233+
boxedLine(row('Branch:', `${currentBranch(state)} (base ${baseBranch(state, settings)})`), width),
234+
boxedLine(row('Safety:', safetyStatus(state)), width),
235+
);
236+
237+
if (hooks) {
238+
lines.push(boxedLine(row('Hooks:', hooks), width));
239+
}
240+
241+
lines.push(
242+
boxedLine(row('Locks:', String(totalLockCount(state))), width),
243+
boxedLine(row('Agents:', availableAgents(state, settings)), width),
244+
emptyLine(width),
245+
boxedLine('Shortcuts', width),
246+
...SHORTCUTS.map(([key, label]) => boxedLine(` ${key} ${label}`, width)),
247+
emptyLine(width),
248+
boxedLine('Next actions', width),
249+
boxedLine(' n new agent - start a guarded agent lane', width),
250+
boxedLine(' t terminal - open a repo terminal', width),
251+
boxedLine(' s settings - tune cockpit defaults', width),
252+
divider(width),
253+
);
254+
255+
return `${lines.join('\n')}\n`;
256+
}
257+
258+
module.exports = {
259+
renderWelcomePage,
260+
};

test/cockpit-welcome.test.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
'use strict';
2+
3+
const assert = require('node:assert/strict');
4+
const test = require('node:test');
5+
6+
const { renderWelcomePage } = require('../src/cockpit/welcome');
7+
8+
test('renderWelcomePage snapshots the empty cockpit welcome strings', () => {
9+
const output = renderWelcomePage({
10+
repoPath: '/work/gitguardex',
11+
currentBranch: 'agent/codex/welcome',
12+
baseBranch: 'main',
13+
hooksStatus: 'core.hooksPath=.githooks',
14+
safetyStatus: 'guarded',
15+
lockCount: 2,
16+
}, {
17+
width: 60,
18+
availableAgents: ['codex', 'claude'],
19+
});
20+
21+
assert.match(output, /gitguardex \| gx cockpit/);
22+
assert.match(output, /Guardian cockpit ready\. No active agent lanes\./);
23+
assert.match(output, /Repo:\s+gitguardex/);
24+
assert.match(output, /Branch:\s+agent\/codex\/welcome \(base main\)/);
25+
assert.match(output, /Safety:\s+guarded/);
26+
assert.match(output, /Hooks:\s+core\.hooksPath=\.githooks/);
27+
assert.match(output, /Locks:\s+2/);
28+
assert.match(output, /Agents:\s+codex, claude/);
29+
assert.match(output, /n new agent/);
30+
assert.match(output, /t terminal/);
31+
assert.match(output, /s settings/);
32+
assert.match(output, /\? shortcuts/);
33+
assert.match(output, /q quit/);
34+
assert.match(output, /Next actions/);
35+
assert.equal(output.endsWith('\n'), true);
36+
});
37+
38+
test('renderWelcomePage stays width bounded and plain terminal safe', () => {
39+
const width = 52;
40+
const output = renderWelcomePage({
41+
repoName: 'very-long-repository-name-that-will-be-truncated',
42+
branch: 'feature/very-long-current-branch-name-that-will-be-truncated',
43+
baseBranch: 'integration',
44+
hooks: { status: 'installed' },
45+
safety: { status: 'ready' },
46+
sessions: [
47+
{ lockCount: 3 },
48+
{ locks: ['src/a.js', 'src/b.js'] },
49+
],
50+
availableAgents: [{ name: 'codex' }, { id: 'gemini' }],
51+
}, { width });
52+
53+
for (const line of output.trimEnd().split('\n')) {
54+
assert.equal(line.length <= width, true, `line exceeded ${width}: ${line}`);
55+
}
56+
57+
assert.match(output, /\/ _\)/);
58+
assert.match(output, /\/ gx \\/);
59+
assert.match(output, /Locks:\s+5/);
60+
assert.match(output, /Agents:\s+codex, gemini/);
61+
assert.doesNotMatch(output, /[\u0080-\uffff]/);
62+
});
63+
64+
test('renderWelcomePage uses defaults when optional state is missing', () => {
65+
const output = renderWelcomePage({}, {});
66+
67+
assert.match(output, /Repo:\s+-/);
68+
assert.match(output, /Branch:\s+- \(base -\)/);
69+
assert.match(output, /Safety:\s+unknown/);
70+
assert.match(output, /Locks:\s+0/);
71+
assert.match(output, /Agents:\s+codex, claude, opencode, cursor, gemini/);
72+
});

0 commit comments

Comments
 (0)