Skip to content

Commit 6015399

Browse files
NagyViktNagyVikt
andauthored
Make cockpit lanes readable at terminal width (#475)
The cockpit needs a dmux-style left rail that can summarize active sessions without depending on color libraries or a wider terminal. This adds a pure renderer over the existing cockpit session state shape and locks its terminal output with focused tests. Constraint: User limited edits to src/cockpit/sidebar.js and test/cockpit-sidebar.test.js Rejected: Wire the sidebar into the main cockpit renderer | would touch files outside the requested scope Rejected: Add a color dependency | ASCII markers and optional ANSI are enough for this surface Confidence: high Scope-risk: narrow Directive: Keep renderSidebar pure and width-bounded; integrate it from cockpit control code in a separate scoped change Tested: node --test test/cockpit-sidebar.test.js Tested: node --test test/cockpit-sidebar.test.js test/cockpit-render.test.js Not-tested: live cockpit keyboard/control-pane integration Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
1 parent e294e2f commit 6015399

2 files changed

Lines changed: 319 additions & 0 deletions

File tree

src/cockpit/sidebar.js

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
const path = require('node:path');
2+
3+
const DEFAULT_WIDTH = 36;
4+
const MIN_WIDTH = 12;
5+
6+
const STATUS_DOTS = new Map([
7+
['active', '*'],
8+
['running', '*'],
9+
['working', '*'],
10+
['thinking', 'o'],
11+
['idle', 'o'],
12+
['ready', 'o'],
13+
['done', '+'],
14+
['complete', '+'],
15+
['completed', '+'],
16+
['merged', '+'],
17+
['blocked', '!'],
18+
['error', '!'],
19+
['failed', '!'],
20+
['stalled', '!'],
21+
['dead', '!'],
22+
]);
23+
24+
const ANSI = {
25+
reset: '\x1b[0m',
26+
dim: '\x1b[2m',
27+
green: '\x1b[32m',
28+
red: '\x1b[31m',
29+
yellow: '\x1b[33m',
30+
cyan: '\x1b[36m',
31+
inverse: '\x1b[7m',
32+
};
33+
34+
function text(value, fallback = '') {
35+
if (typeof value === 'string') {
36+
return value.trim() || fallback;
37+
}
38+
if (value === null || value === undefined) {
39+
return fallback;
40+
}
41+
return String(value).trim() || fallback;
42+
}
43+
44+
function sidebarWidth(options = {}) {
45+
const width = Number(options.width);
46+
if (!Number.isFinite(width)) {
47+
return DEFAULT_WIDTH;
48+
}
49+
return Math.max(MIN_WIDTH, Math.floor(width));
50+
}
51+
52+
function truncate(value, width) {
53+
const raw = value === null || value === undefined ? '' : String(value);
54+
if (width <= 0) {
55+
return '';
56+
}
57+
if (raw.length <= width) {
58+
return raw;
59+
}
60+
if (width <= 3) {
61+
return raw.slice(0, width);
62+
}
63+
return `${raw.slice(0, width - 3)}...`;
64+
}
65+
66+
function boundLine(value, width) {
67+
return truncate(value, width);
68+
}
69+
70+
function repoName(state = {}, options = {}) {
71+
const explicit = text(options.repoName || state.repoName || state.projectName || state.repo);
72+
if (explicit) {
73+
return explicit;
74+
}
75+
76+
const repoPath = text(state.repoPath);
77+
if (!repoPath) {
78+
return '-';
79+
}
80+
return path.basename(repoPath) || repoPath;
81+
}
82+
83+
function agentLabel(agentName) {
84+
const compact = text(agentName, 'agent').replace(/[^a-z0-9]/gi, '').toUpperCase();
85+
return truncate(compact || 'AGENT', 3).padEnd(3, ' ');
86+
}
87+
88+
function statusDot(session = {}) {
89+
if (session.worktreeExists === false) {
90+
return 'x';
91+
}
92+
const status = text(session.status, 'unknown').toLowerCase();
93+
return STATUS_DOTS.get(status) || '.';
94+
}
95+
96+
function lockCount(session = {}) {
97+
if (Array.isArray(session.locks)) {
98+
return session.locks.length;
99+
}
100+
const count = Number(session.lockCount);
101+
return Number.isFinite(count) && count >= 0 ? count : 0;
102+
}
103+
104+
function sessionId(session = {}) {
105+
return text(session.id || session.sessionId || session.branch);
106+
}
107+
108+
function isSelected(session, index, state = {}, options = {}) {
109+
const selectedId = text(options.selectedId || options.selectedSessionId || state.selectedId || state.selectedSessionId);
110+
if (selectedId && sessionId(session) === selectedId) {
111+
return true;
112+
}
113+
114+
const selectedBranch = text(options.selectedBranch || state.selectedBranch);
115+
if (selectedBranch && text(session.branch) === selectedBranch) {
116+
return true;
117+
}
118+
119+
const selectedIndex = Number.isInteger(options.selectedIndex) ? options.selectedIndex : state.selectedIndex;
120+
return Number.isInteger(selectedIndex) && selectedIndex === index;
121+
}
122+
123+
function colorize(value, color, options = {}) {
124+
if (options.noColor || options.color !== true) {
125+
return value;
126+
}
127+
const code = ANSI[color];
128+
return code ? `${code}${value}${ANSI.reset}` : value;
129+
}
130+
131+
function statusColor(dot) {
132+
if (dot === '*') {
133+
return 'green';
134+
}
135+
if (dot === '!') {
136+
return 'yellow';
137+
}
138+
if (dot === 'x') {
139+
return 'red';
140+
}
141+
if (dot === '+') {
142+
return 'cyan';
143+
}
144+
return 'dim';
145+
}
146+
147+
function renderSessionRow(session, index, state, options) {
148+
const width = sidebarWidth(options);
149+
const selected = isSelected(session, index, state, options);
150+
const marker = selected ? '>' : ' ';
151+
const dot = statusDot(session);
152+
const label = agentLabel(session.agentName);
153+
const branch = text(session.branch, '(no branch)');
154+
const task = text(session.task, '(no task)');
155+
const missing = session.worktreeExists === false ? ' missing worktree' : '';
156+
157+
const firstPrefix = `${marker} ${dot} ${label} `;
158+
const first = `${firstPrefix}${truncate(branch, width - firstPrefix.length)}`;
159+
const taskPrefix = ' ';
160+
const taskLine = `${taskPrefix}${truncate(task, width - taskPrefix.length)}`;
161+
const meta = ` locks: ${lockCount(session)}${missing}`;
162+
163+
return [
164+
selected ? colorize(boundLine(first, width), 'inverse', options) : boundLine(first, width),
165+
boundLine(taskLine, width),
166+
colorize(boundLine(meta, width), statusColor(dot), options),
167+
];
168+
}
169+
170+
function renderSidebar(state = {}, options = {}) {
171+
const width = sidebarWidth(options);
172+
const title = text(options.title || state.title, 'gx cockpit').toLowerCase() === 'gitguardex'
173+
? 'gitguardex'
174+
: text(options.title || state.title, 'gx cockpit');
175+
const sessions = Array.isArray(state.sessions) ? state.sessions : [];
176+
const lines = [
177+
boundLine(title, width),
178+
boundLine(`repo ${repoName(state, options)}`, width),
179+
boundLine('-'.repeat(width), width),
180+
boundLine('lanes', width),
181+
];
182+
183+
if (sessions.length === 0) {
184+
lines.push(boundLine(' no active lanes', width));
185+
} else {
186+
sessions.forEach((session, index) => {
187+
lines.push(...renderSessionRow(session, index, state, options));
188+
});
189+
}
190+
191+
lines.push(
192+
boundLine('-'.repeat(width), width),
193+
boundLine('[n] new agent', width),
194+
boundLine('[t] terminal', width),
195+
boundLine('[s] settings', width),
196+
);
197+
198+
return `${lines.join('\n')}\n`;
199+
}
200+
201+
module.exports = {
202+
renderSidebar,
203+
agentLabel,
204+
statusDot,
205+
truncate,
206+
};

test/cockpit-sidebar.test.js

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
const { test } = require('node:test');
2+
const assert = require('node:assert/strict');
3+
4+
const { renderSidebar } = require('../src/cockpit/sidebar');
5+
6+
function lines(output) {
7+
return output.trimEnd().split('\n');
8+
}
9+
10+
test('renderSidebar renders an empty sidebar', () => {
11+
const output = renderSidebar({
12+
repoPath: '/work/gitguardex',
13+
sessions: [],
14+
}, { noColor: true });
15+
16+
assert.match(output, /gx cockpit/);
17+
assert.match(output, /repo gitguardex/);
18+
assert.match(output, /lanes/);
19+
assert.match(output, /no active lanes/);
20+
assert.match(output, /\[n\] new agent/);
21+
assert.match(output, /\[t\] terminal/);
22+
assert.match(output, /\[s\] settings/);
23+
});
24+
25+
test('renderSidebar marks the selected session', () => {
26+
const output = renderSidebar({
27+
repoName: 'gitguardex',
28+
selectedSessionId: 's2',
29+
sessions: [
30+
{
31+
id: 's1',
32+
agentName: 'codex',
33+
branch: 'agent/codex/first',
34+
task: 'first lane',
35+
status: 'idle',
36+
lockCount: 0,
37+
worktreeExists: true,
38+
},
39+
{
40+
id: 's2',
41+
agentName: 'claude',
42+
branch: 'agent/claude/second',
43+
task: 'selected lane',
44+
status: 'working',
45+
lockCount: 2,
46+
worktreeExists: true,
47+
},
48+
],
49+
}, { noColor: true });
50+
51+
assert.match(output, /^ o COD agent\/codex\/first$/m);
52+
assert.match(output, /^> \* CLA agent\/claude\/second$/m);
53+
});
54+
55+
test('renderSidebar marks a missing worktree', () => {
56+
const output = renderSidebar({
57+
repoName: 'gitguardex',
58+
sessions: [
59+
{
60+
id: 'missing',
61+
agentName: 'codex',
62+
branch: 'agent/codex/missing',
63+
task: 'repair missing lane',
64+
status: 'stalled',
65+
lockCount: 1,
66+
worktreeExists: false,
67+
},
68+
],
69+
}, { noColor: true });
70+
71+
assert.match(output, /^ x COD agent\/codex\/missing$/m);
72+
assert.match(output, /locks: 1 missing worktree/);
73+
});
74+
75+
test('renderSidebar truncates long branch and task text', () => {
76+
const output = renderSidebar({
77+
repoName: 'gitguardex',
78+
sessions: [
79+
{
80+
id: 'long',
81+
agentName: 'codex',
82+
branch: 'agent/codex/this-branch-name-is-too-long-for-the-sidebar',
83+
task: 'this task description is also too long for the bounded dmux-style sidebar',
84+
status: 'working',
85+
lockCount: 0,
86+
worktreeExists: true,
87+
},
88+
],
89+
}, { width: 30, noColor: true });
90+
91+
assert.ok(lines(output).every((line) => line.length <= 30));
92+
assert.match(output, /agent\/codex\/this-br\.\.\./);
93+
assert.match(output, /this task description i\.\.\./);
94+
});
95+
96+
test('renderSidebar displays lock counts', () => {
97+
const output = renderSidebar({
98+
repoName: 'gitguardex',
99+
sessions: [
100+
{
101+
id: 'locks',
102+
agentName: 'codex',
103+
branch: 'agent/codex/locks',
104+
task: 'lock count lane',
105+
status: 'working',
106+
lockCount: 7,
107+
worktreeExists: true,
108+
},
109+
],
110+
}, { noColor: true });
111+
112+
assert.match(output, /locks: 7/);
113+
});

0 commit comments

Comments
 (0)