gitroots 0.0.0-stage → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,270 @@
1
+ // /status · /repos · /repo · /approvals · /pushes · /audit · /activity · /members (all live from gitroots)
2
+ import { select, interactive, ask, confirm } from '../ui/term.js';
3
+ import { json } from '../ui/components.js';
4
+ import { localSessions } from '../agents/local.js';
5
+ import { c, h, line, pad, fit, next, needWorkspace, load, ago, empty, table, cols, cmdText } from './helpers.js';
6
+
7
+ const plural = (n, w) => `${n} ${n === 1 ? w : w.endsWith('y') && !/[aeiou]y$/.test(w) ? `${w.slice(0, -1)}ies` : w.endsWith('s') || w.endsWith('sh') || w.endsWith('ch') ? `${w}es` : w === 'person' ? 'people' : `${w}s`}`;
8
+
9
+ // ---------------------------------------------------------------- /status
10
+
11
+ export async function status(ctx) {
12
+ needWorkspace(ctx);
13
+ const [home, agents] = await load(ctx, 'Loading your workspace…', () => Promise.all([
14
+ ctx.api.home({ signal: ctx.signal }),
15
+ ctx.api.agents({ signal: ctx.signal }).catch(() => null),
16
+ ]));
17
+ const local = localSessions({ limit: 30 });
18
+ const waitingAgents = (agents?.sessions || []).filter((s) => s.needs_you);
19
+ if (ctx.json) { json({ ...home, agents_waiting: waitingAgents, local_agents_live: local.filter((s) => s.status === 'live').length }); return 0; }
20
+
21
+ const p = home.posture || {};
22
+ const cov = p.coverage || {};
23
+ line(`${c.g(c.b('gitroots'))} ${c.m('·')} ${c.b(home.organization.name)} ${c.m('·')} ${home.user.display_name || home.user.name} ${c.m('·')} ${c.m(ctx.api.host)}`);
24
+ line();
25
+ line(h('Posture'));
26
+ const inPolicy = p.all_in_policy ? c.g('✓ all in policy') : c.w('◐ not all in policy');
27
+ line(` ${plural(p.repo_count || 0, 'repository')} ${c.m('·')} ${p.protected_count || 0} protected ${c.m('·')} ${cov.overlay || 0} shared by overlay ${c.m('·')} ${cov.open || 0} open ${inPolicy}`);
28
+ line(` ${plural(home.developer_count || 0, 'developer')} ${c.m('·')} ${home.blocked_24h ? c.r(`✕ ${plural(home.blocked_24h, 'blocked push')} in 24h`) : c.g('✓ no blocked pushes in 24h')}`);
29
+ line();
30
+ line(h('Needs you'));
31
+ let any = false;
32
+ for (const a of home.awaiting || []) {
33
+ any = true;
34
+ line(` ${c.w(pad('◐ review', 11))} ${fit(a.title, 44)} ${c.m(fit(`${a.meta || ''} · ${a.age || ''}`, Math.max(10, cols() - 62)))}`);
35
+ }
36
+ if (home.pending_count > (home.awaiting || []).length) line(` ${c.m(`+ ${home.pending_count - (home.awaiting || []).length} more`)}`);
37
+ for (const s of waitingAgents.slice(0, 4)) {
38
+ any = true;
39
+ line(` ${c.w(pad('◐ agent', 11))} ${fit(`${s.agent_name} on ${s.machine}: ${s.ask?.what || s.title}`, 44)} ${c.m(fit(s.ask?.cmd || '', Math.max(10, cols() - 62)))}`);
40
+ }
41
+ if (!any) line(` ${c.g('✓ nothing waiting on you')}`);
42
+ line();
43
+ const live = local.filter((s) => s.status === 'live').length;
44
+ const remote = (agents?.sessions || []).filter((s) => s.active).length;
45
+ line(h('Agents'));
46
+ line(` ${remote ? c.g(`● ${remote} active on your machines`) : c.m('○ none reported by the gitroots app')} ${c.m('·')} ${live ? c.g(`● ${live} live on this computer`) : c.m(`○ ${local.length} recent on this computer`)}`);
47
+ line();
48
+ if (home.pending_count) next(ctx, 'approvals');
49
+ else if (waitingAgents.length) next(ctx, 'agents');
50
+ else if (home.blocked_24h) next(ctx, 'pushes', '--blocked');
51
+ else next(ctx, 'repos');
52
+ return 0;
53
+ }
54
+
55
+ // ---------------------------------------------------------------- /repos, /repo
56
+
57
+ export async function repos(ctx) {
58
+ needWorkspace(ctx);
59
+ const d = await load(ctx, 'Loading repositories…', () => ctx.api.repos({ signal: ctx.signal }));
60
+ if (ctx.json) { json(d.repos); return 0; }
61
+ if (!d.repos.length) {
62
+ empty(d.github?.connected === false ? 'no repositories · connect GitHub in the gitroots dashboard' : 'no repositories synced yet');
63
+ return 0;
64
+ }
65
+ table([
66
+ { label: 'REPOSITORY', w: 34, fmt: (r) => c.b(r.full_name) },
67
+ { label: 'DEFAULT', w: 12, key: 'default_branch' },
68
+ { label: 'BRANCHES', w: 10, key: 'branch_count' },
69
+ { label: 'RULES', w: 8, key: 'rule_count' },
70
+ { label: 'OVERLAYS', w: 10, key: 'overlay_count' },
71
+ { label: 'STATUS', w: 16, fmt: (r) => (r.protected ? c.g('● protected') : c.m('○ open')) },
72
+ ], d.repos);
73
+ line();
74
+ next(ctx, 'repo', d.repos[0].name);
75
+ return 0;
76
+ }
77
+
78
+ async function pickRepo(ctx) {
79
+ const d = await load(ctx, 'Loading repositories…', () => ctx.api.repos({ signal: ctx.signal }));
80
+ if (!d.repos.length) throw new Error('no repositories in this workspace yet');
81
+ if (!interactive()) throw new Error(`which repository? ${cmdText(ctx, 'repo', '<name>')}`);
82
+ line(`${c.i(c.b('?'))} Which repository?`);
83
+ const res = await select({
84
+ count: d.repos.length,
85
+ render: (i) => windowed(d.repos, i, 10, (r, sel) => `${sel ? c.g('❯ ') : ' '}${sel ? c.b(pad(r.full_name, 40)) : pad(r.full_name, 40)}${r.protected ? c.g('● protected') : c.m('○ open')}`),
86
+ });
87
+ return res.action === 'cancel' ? null : d.repos[res.index].full_name;
88
+ }
89
+
90
+ export function windowed(items, idx, max, row) {
91
+ const start = Math.max(0, Math.min(idx - Math.floor(max / 2), items.length - max));
92
+ const out = items.slice(start, start + max).map((it, j) => row(it, start + j === idx));
93
+ if (items.length > max) out.push(c.m(` ${idx + 1} of ${items.length} · ↑↓ to move · enter to choose · esc to cancel`));
94
+ return out.join('\n');
95
+ }
96
+
97
+ export async function repo(ctx, args) {
98
+ needWorkspace(ctx);
99
+ const name = args[0] || await pickRepo(ctx);
100
+ if (!name) return 1;
101
+ const r = await load(ctx, `Loading ${name}…`, () => ctx.api.repo(name, { signal: ctx.signal }));
102
+ if (ctx.json) { json(r); return 0; }
103
+ line(`${c.b(r.full_name)} ${r.protected ? c.g('● protected') : c.m('○ open')} ${c.m(`default ${r.default_branch} · ${plural(r.branches.length, 'branch')} · ${plural(r.overlay_count, 'overlay')}`)}`);
104
+ line();
105
+ line(h('Branch rules'));
106
+ if (!r.branch_rules.length) empty(`no rules${r.can_manage ? ' · add them on gitroots.com › Repositories' : ''}`);
107
+ for (const b of r.branch_rules) {
108
+ const push = b.allow_direct_push ? c.g('✓ direct push') : c.r('✕ no direct push');
109
+ line(` ${c.b(pad(b.pattern, 22))}${pad(b.level === 'leads' ? c.w('◐ leads only') : b.level === 'open' ? c.m('○ open') : c.g(`● ${b.level}`), 16)}${pad(push, 20)}${c.m(b.description)}`);
110
+ }
111
+ if (r.github_protected_branches?.length) line(` ${c.m(`GitHub protects: ${r.github_protected_branches.join(', ')}`)}`);
112
+ if (r.environments?.length) {
113
+ line();
114
+ line(h('Environments'));
115
+ for (const e of r.environments) line(` ${pad(e.name, 18)}${pad(e.branch || '', 22)}${c.m(`${e.audience || ''} · ${e.state || ''}`)}`);
116
+ }
117
+ line();
118
+ line(h(`People · ${r.developer_count}${r.external_count ? ` (${r.external_count} external)` : ''}`));
119
+ for (const p of r.people.slice(0, 12)) line(` ${pad(p.display_name || p.name, 26)}${c.m(p.detail || p.kind)}`);
120
+ if (r.people.length > 12) line(` ${c.m(`+ ${r.people.length - 12} more`)}`);
121
+ line();
122
+ line(h('Branches'));
123
+ if (!r.branches.length) empty('none synced yet · gitroots fetches them from GitHub');
124
+ else line(` ${fit(r.branches.slice(0, 30).join(' '), cols() - 4)}${r.branches.length > 30 ? c.m(` + ${r.branches.length - 30} more`) : ''}`);
125
+ return 0;
126
+ }
127
+
128
+ // ---------------------------------------------------------------- /approvals
129
+
130
+ function approvalRow(a, sel) {
131
+ const mark = a.status === 'failed' ? c.r('✕ failed ') : a.status === 'queued' ? c.i('○ merging') : a.type === 'access' ? c.w('◐ access ') : c.w('◐ merge ');
132
+ const who = a.requested_by?.display_name || a.user?.display_name || '';
133
+ const t = `${fit(a.title, 40)} ${c.m(fit([who, a.meta, a.age].filter(Boolean).join(' · '), Math.max(10, cols() - 62)))}`;
134
+ return `${sel ? c.g('❯ ') : ' '}${mark} ${sel ? c.b(t) : t}`;
135
+ }
136
+
137
+ export async function approvals(ctx, args) {
138
+ needWorkspace(ctx);
139
+ const state = ['waiting', 'approved', 'declined'].includes(args[0]) ? args[0] : 'waiting';
140
+ const d = await load(ctx, 'Loading approvals…', () => ctx.api.approvals(state, { signal: ctx.signal }));
141
+ if (ctx.json) { json(d); return 0; }
142
+ if (!d.items.length) { line(`${c.g('✓')} nothing ${state === 'waiting' ? 'waiting on approval' : state}`); return 0; }
143
+ if (!ctx.session || !interactive() || state !== 'waiting') {
144
+ for (const a of d.items) line(approvalRow(a, false));
145
+ line();
146
+ next(ctx, 'approvals');
147
+ return 0;
148
+ }
149
+ line(`${c.i(c.b('?'))} ${plural(d.waiting_count, 'request')} waiting · pick one`);
150
+ const res = await select({ count: d.items.length, render: (i) => windowed(d.items, i, 8, approvalRow) });
151
+ if (res.action === 'cancel') return 0;
152
+ return decideApproval(ctx, d.items[res.index]);
153
+ }
154
+
155
+ async function decideApproval(ctx, a) {
156
+ line();
157
+ line(`${c.b(a.title)} ${c.m(a.kind)}`);
158
+ if (a.type === 'sync') {
159
+ line(` ${c.s(pad('from', 10))}${a.from}:${a.from_branch} → ${a.to}:${a.to_branch}`);
160
+ line(` ${c.s(pad('changes', 10))}${plural(a.commits || 0, 'commit')}${a.files != null ? ` · ${plural(a.files, 'file')} ${c.g(`+${a.additions || 0}`)} ${c.r(`−${a.deletions || 0}`)}` : ''}`);
161
+ if (a.requested_by) line(` ${c.s(pad('by', 10))}${a.requested_by.display_name} ${c.m(a.requester_meta || '')}`);
162
+ if (a.ai_risk) line(` ${c.s(pad('ai review', 10))}${a.ai_risk}`);
163
+ } else {
164
+ line(` ${c.s(pad('who', 10))}${a.user?.display_name} ${c.m(a.requester_meta || '')}`);
165
+ line(` ${c.s(pad('wants', 10))}${a.rule.replace(/_/g, ' ')} on ${a.repo}${a.branch ? ` (${a.branch})` : ''} for ${a.days} days ${a.within_policy ? c.g('✓ within policy') : c.w(`◐ policy max ${a.policy_max_days} days`)}`);
166
+ }
167
+ if (a.note) line(` ${c.s(pad('note', 10))}${a.note}`);
168
+ line();
169
+ const actions = a.type === 'sync'
170
+ ? (a.status === 'failed' ? [['retry', 'Retry the merge'], ['decline', 'Decline'], ['back', 'Back']]
171
+ : a.status === 'queued' ? [['back', 'Back (merge in progress)']]
172
+ : [['approve', 'Approve and merge'], ['changes', 'Request changes'], ['decline', 'Decline'], ['back', 'Back']])
173
+ : [['approve', 'Approve access'], ['decline', 'Decline'], ['back', 'Back']];
174
+ const pick = await select({ count: actions.length, render: (i) => actions.map(([, label], j) => `${j === i ? c.g('❯ ') : ' '}${j === i ? c.b(label) : label}`).join('\n') });
175
+ if (pick.action === 'cancel') return 0;
176
+ const act = actions[pick.index][0];
177
+ if (act === 'back') return 0;
178
+ let result;
179
+ if (a.type === 'sync' && (act === 'approve' || act === 'retry')) {
180
+ if (!(await confirm(`Merge ${a.from_branch} into ${a.to}:${a.to_branch}?`, { def: false }))) { line(c.m(' nothing changed')); return 0; }
181
+ const note = await ask('Note (optional)');
182
+ result = await load(ctx, 'Approving…', () => ctx.api.approveSync(a.id, note, { signal: ctx.signal }));
183
+ line(`${c.g('✓')} Approved ${c.b(a.title)} ${c.m(`· ${result?.detail === 'queued' ? 'merge queued' : 'merged'}`)}`);
184
+ } else if (a.type === 'sync') {
185
+ const reason = await ask(act === 'changes' ? 'What needs to change?' : 'Reason (optional)');
186
+ if (act === 'changes' && !reason) { line(c.m(' nothing sent')); return 0; }
187
+ await load(ctx, 'Sending…', () => ctx.api.declineSync(a.id, act === 'changes' ? 'changes' : 'decline', reason, { signal: ctx.signal }));
188
+ line(`${act === 'changes' ? c.w('◐ Changes requested on') : c.r('✕ Declined')} ${c.b(a.title)}`);
189
+ } else {
190
+ const reason = act === 'decline' ? await ask('Reason (optional)') : '';
191
+ await load(ctx, 'Saving…', () => ctx.api.decideAccess(a.id, act, reason, { signal: ctx.signal }));
192
+ line(`${act === 'approve' ? c.g('✓ Approved') : c.r('✕ Declined')} ${c.b(a.title)}`);
193
+ }
194
+ next(ctx, 'approvals');
195
+ return 0;
196
+ }
197
+
198
+ // ---------------------------------------------------------------- /pushes, /audit, /activity, /members
199
+
200
+ export async function pushes(ctx, args, flags) {
201
+ needWorkspace(ctx);
202
+ const blocked = Boolean(flags.blocked || args.includes('blocked'));
203
+ const d = await load(ctx, 'Loading pushes…', () => ctx.api.pushEvents(blocked, { signal: ctx.signal }));
204
+ if (ctx.json) { json(d.items); return 0; }
205
+ if (!d.items.length) { empty(blocked ? 'no blocked pushes' : 'no pushes recorded yet'); return 0; }
206
+ table([
207
+ { label: 'WHEN', w: 18, fmt: (e) => c.m(`${e.date} ${e.time}`) },
208
+ { label: 'REPO', w: 22, key: 'repo_name' },
209
+ { label: 'BRANCH', w: 24, key: 'branch' },
210
+ { label: 'BY', w: 16, key: 'pusher' },
211
+ { label: 'COMMITS', w: 9, key: 'commit_count' },
212
+ { label: 'RESULT', w: 30, fmt: (e) => (e.blocked ? c.r(`✕ blocked ${e.rule ? `· ${e.rule}` : ''}`) : c.g('✓ allowed')) },
213
+ ], d.items.slice(0, 40));
214
+ return 0;
215
+ }
216
+
217
+ const CATEGORIES = ['All', 'Blocked', 'Merges', 'Access', 'Other'];
218
+
219
+ export async function audit(ctx, args, flags) {
220
+ needWorkspace(ctx);
221
+ const category = CATEGORIES.find((x) => x.toLowerCase() === String(args[0] || flags.category || 'all').toLowerCase()) || 'All';
222
+ const q = flags.q || flags.search || (CATEGORIES.some((x) => x.toLowerCase() === String(args[0]).toLowerCase()) ? args.slice(1) : args).join(' ');
223
+ const d = await load(ctx, 'Loading audit log…', () => ctx.api.audit(category, q, { signal: ctx.signal }));
224
+ if (ctx.json) { json(d.items); return 0; }
225
+ if (!d.items.length) { empty('no events match'); return 0; }
226
+ const summary = (m) => Object.entries(m || {}).filter(([, v]) => v !== '' && v !== null && typeof v !== 'object').slice(0, 3).map(([k, v]) => `${k} ${v}`).join(' · ');
227
+ table([
228
+ { label: 'WHEN', w: 17, fmt: (e) => c.m(`${e.day.slice(5)} ${e.time}`) },
229
+ { label: 'WHO', w: 20, key: 'actor_short' },
230
+ { label: 'WHAT', w: 30, fmt: (e) => (e.category === 'Blocked' ? c.r(e.action_display) : e.action_display) },
231
+ { label: 'DETAILS', w: 40, fmt: (e) => c.m(summary(e.metadata)) },
232
+ ], d.items.slice(0, 50));
233
+ line(` ${c.m(`${Math.min(50, d.items.length)} of ${d.items.length} · categories: ${CATEGORIES.join(', ')} · e.g. ${cmdText(ctx, 'audit', 'blocked')}`)}`);
234
+ return 0;
235
+ }
236
+
237
+ export async function activity(ctx) {
238
+ needWorkspace(ctx);
239
+ const d = await load(ctx, 'Loading team activity…', () => ctx.api.teamActivity({ signal: ctx.signal }));
240
+ if (ctx.json) { json(d); return 0; }
241
+ const delta = d.delta_pct === null || d.delta_pct === undefined ? '' : d.delta_pct >= 0 ? c.g(` ↑ ${d.delta_pct}%`) : c.r(` ↓ ${Math.abs(d.delta_pct)}%`);
242
+ line(`${c.b(d.organization)} ${c.m(`· last ${d.weeks} weeks`)}`);
243
+ line(` ${plural(d.commit_count || 0, 'commit')} ${c.m('·')} ${plural(d.merged_count || 0, 'merge')} ${c.m('·')} ${plural(d.people_count || 0, 'person')}${delta}`);
244
+ // heatmap: one row of the weekly levels
245
+ const levels = (d.cells || []).map((x) => (typeof x === 'object' ? x.level ?? 0 : x));
246
+ if (levels.length) {
247
+ const shades = [c.m('·'), c.g('▁'), c.g('▃'), c.g('▅'), c.g('█')];
248
+ line(` ${levels.slice(-Math.min(levels.length, 70)).map((l) => shades[Math.max(0, Math.min(4, l))]).join('')} ${c.m('daily, oldest → today')}`);
249
+ }
250
+ for (const [title, rows] of [['Most active', d.most_active], ['Leads', d.leads], ['External', d.external]]) {
251
+ if (!rows || !rows.length) continue;
252
+ line();
253
+ line(h(title));
254
+ for (const p of rows.slice(0, 8)) line(` ${pad(p.display_name || p.name, 26)}${pad(`${p.commits} commits · ${p.merged} merged`, 30)}${c.m(p.detail || p.role || '')}`);
255
+ }
256
+ return 0;
257
+ }
258
+
259
+ export async function members(ctx) {
260
+ needWorkspace(ctx);
261
+ const d = await load(ctx, 'Loading members…', () => ctx.api.members({ signal: ctx.signal }));
262
+ if (ctx.json) { json(d.members); return 0; }
263
+ table([
264
+ { label: 'NAME', w: 28, fmt: (m) => c.b(m.display_name || m.name) },
265
+ { label: 'EMAIL', w: 34, key: 'email' },
266
+ { label: 'ROLE', w: 16, fmt: (m) => m.role_display || m.role },
267
+ { label: '', w: 10, fmt: (m) => (m.external ? c.w('external') : '') },
268
+ ], d.members);
269
+ return 0;
270
+ }
package/src/ui/ansi.js ADDED
@@ -0,0 +1,138 @@
1
+ // Colors, widths and padding. Palette comes straight from the design tokens:
2
+ // g #60DD65 success and the one next action
3
+ // m #A5B8B1 muted
4
+ // s #C9D6D0 secondary
5
+ // w #F5BF57 needs you
6
+ // r #FF7474 real block or failure
7
+ // i #73C5FF in progress / info
8
+ // fg #F1F7F3
9
+ // Colors drop when output is piped or NO_COLOR is set.
10
+
11
+ const env = process.env;
12
+
13
+ function detectColor(stream = process.stdout) {
14
+ if (env.NO_COLOR !== undefined && env.NO_COLOR !== '') return false;
15
+ if (env.FORCE_COLOR !== undefined) return env.FORCE_COLOR !== '0';
16
+ if (env.TERM === 'dumb') return false;
17
+ return Boolean(stream.isTTY);
18
+ }
19
+
20
+ function detectTruecolor() {
21
+ const ct = (env.COLORTERM || '').toLowerCase();
22
+ if (ct === 'truecolor' || ct === '24bit') return true;
23
+ const tp = env.TERM_PROGRAM || '';
24
+ if (['iTerm.app', 'WezTerm', 'ghostty', 'vscode', 'Hyper'].includes(tp)) return true;
25
+ if (env.WT_SESSION) return true;
26
+ const term = env.TERM || '';
27
+ return /kitty|ghostty|direct|truecolor/.test(term);
28
+ }
29
+
30
+ export const state = {
31
+ color: detectColor(),
32
+ truecolor: detectTruecolor(),
33
+ };
34
+
35
+ export function setColor(on) { state.color = on; }
36
+
37
+ const PALETTE = {
38
+ g: [0x60, 0xdd, 0x65, 77],
39
+ m: [0xa5, 0xb8, 0xb1, 109],
40
+ s: [0xc9, 0xd6, 0xd0, 152],
41
+ w: [0xf5, 0xbf, 0x57, 221],
42
+ r: [0xff, 0x74, 0x74, 210],
43
+ i: [0x73, 0xc5, 0xff, 117],
44
+ fg: [0xf1, 0xf7, 0xf3, 255],
45
+ ink: [0x07, 0x14, 0x11, 233],
46
+ };
47
+
48
+ const BG = {
49
+ bar: [0x0f, 0x26, 0x20, 234],
50
+ attach: [0x12, 0x44, 0x35, 23],
51
+ sel: [0x12, 0x44, 0x35, 23],
52
+ key: [0x1a, 0x33, 0x2c, 236],
53
+ red: [0x3a, 0x1c, 0x1c, 52],
54
+ green: [0x60, 0xdd, 0x65, 77],
55
+ };
56
+
57
+ function fgCode([r, g, b, n]) { return state.truecolor ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${n}m`; }
58
+ function bgCode([r, g, b, n]) { return state.truecolor ? `\x1b[48;2;${r};${g};${b}m` : `\x1b[48;5;${n}m`; }
59
+
60
+ function paint(code, reset) {
61
+ return (s) => (state.color ? `${code()}${s}${reset}` : String(s));
62
+ }
63
+
64
+ export const c = {
65
+ g: paint(() => fgCode(PALETTE.g), '\x1b[39m'),
66
+ m: paint(() => fgCode(PALETTE.m), '\x1b[39m'),
67
+ s: paint(() => fgCode(PALETTE.s), '\x1b[39m'),
68
+ w: paint(() => fgCode(PALETTE.w), '\x1b[39m'),
69
+ r: paint(() => fgCode(PALETTE.r), '\x1b[39m'),
70
+ i: paint(() => fgCode(PALETTE.i), '\x1b[39m'),
71
+ fg: paint(() => fgCode(PALETTE.fg), '\x1b[39m'),
72
+ ink: paint(() => fgCode(PALETTE.ink), '\x1b[39m'),
73
+ b: paint(() => '\x1b[1m', '\x1b[22m'),
74
+ dim: paint(() => '\x1b[2m', '\x1b[22m'),
75
+ inv: paint(() => '\x1b[7m', '\x1b[27m'),
76
+ // Backgrounds nest: an inner reset falls back to this background, not the terminal's.
77
+ bg: (name) => (s) => {
78
+ if (!state.color) return String(s);
79
+ const code = bgCode(BG[name]);
80
+ return `${code}${String(s).split('\x1b[49m').join(code).split('\x1b[0m').join(`\x1b[0m${code}`)}\x1b[49m`;
81
+ },
82
+ };
83
+
84
+ // Header labels: muted caps, like the design's small letter-spaced headings.
85
+ export const h = (s) => c.m(String(s).toUpperCase());
86
+
87
+ const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b_[^\x1b]*\x1b\\/g;
88
+
89
+ export function strip(s) { return String(s).replace(ANSI_RE, ''); }
90
+
91
+ // Visible width. All glyphs the CLI prints (box drawing, ●◐○✓✕, braille spinners) are single-width.
92
+ export function width(s) {
93
+ let n = 0;
94
+ for (const ch of strip(s)) {
95
+ const cp = ch.codePointAt(0);
96
+ if (cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f) || (cp >= 0x300 && cp <= 0x36f)) continue;
97
+ n += isWide(cp) ? 2 : 1;
98
+ }
99
+ return n;
100
+ }
101
+
102
+ function isWide(cp) {
103
+ return (cp >= 0x1100 && cp <= 0x115f) || (cp >= 0x2e80 && cp <= 0xa4cf) || (cp >= 0xac00 && cp <= 0xd7a3) ||
104
+ (cp >= 0xf900 && cp <= 0xfaff) || (cp >= 0xfe30 && cp <= 0xfe4f) || (cp >= 0xff00 && cp <= 0xff60) ||
105
+ (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x1f300 && cp <= 0x1faff) || (cp >= 0x20000 && cp <= 0x3fffd);
106
+ }
107
+
108
+ export function pad(s, n) {
109
+ const w = width(s);
110
+ return w >= n ? s : s + ' '.repeat(n - w);
111
+ }
112
+
113
+ export function padStart(s, n) {
114
+ const w = width(s);
115
+ return w >= n ? s : ' '.repeat(n - w) + s;
116
+ }
117
+
118
+ // Cut a (possibly colored) string to n visible columns.
119
+ export function truncate(s, n) {
120
+ if (width(s) <= n) return s;
121
+ let out = '';
122
+ let w = 0;
123
+ let i = 0;
124
+ const str = String(s);
125
+ while (i < str.length) {
126
+ ANSI_RE.lastIndex = i;
127
+ const m = ANSI_RE.exec(str);
128
+ if (m && m.index === i) { out += m[0]; i += m[0].length; continue; }
129
+ const ch = String.fromCodePoint(str.codePointAt(i));
130
+ const cw = width(ch);
131
+ if (w + cw > n - 1) { out += '…'; break; }
132
+ out += ch; w += cw; i += ch.length;
133
+ }
134
+ return state.color ? `${out}\x1b[0m` : out;
135
+ }
136
+
137
+ // Fit to exactly n columns: truncate or pad.
138
+ export function fit(s, n) { return pad(truncate(s, n), n); }
@@ -0,0 +1,85 @@
1
+ import { c, h, pad, width, strip, state as colorState } from './ansi.js';
2
+ import { line } from './term.js';
3
+
4
+ export const brand = () => c.g(c.b('gitroots'));
5
+ export const dot = () => c.m('·');
6
+ export const sep = (...parts) => parts.filter((p) => p !== undefined && p !== null && p !== '').join(` ${dot()} `);
7
+
8
+ // Every screen ends with the next command.
9
+ export function next(cmd, alt) {
10
+ const tail = alt ? ` ${c.m('or')} ${c.s(alt)}` : '';
11
+ return `${c.g(`→ ${cmd}`)}${tail}`;
12
+ }
13
+
14
+ // State words always pair a symbol with a word.
15
+ export const mark = {
16
+ running: (w = 'running') => c.g(`● ${w}`),
17
+ waiting: (w = 'waiting') => c.w(`◐ ${w}`),
18
+ queued: (w = 'queued') => c.m(`○ ${w}`),
19
+ done: (w = 'done') => c.g(`✓ ${w}`),
20
+ blocked: (w = 'blocked') => c.r(`✕ ${w}`),
21
+ idle: (w = 'idle') => c.s(`○ ${w}`),
22
+ suspended: (w = 'suspended') => c.m(`⏸ ${w}`),
23
+ offline: (w = 'offline') => c.m(`○ ${w}`),
24
+ };
25
+
26
+ export function sessionState(s) {
27
+ switch (s.state) {
28
+ case 'running': return mark.running();
29
+ case 'waiting': return mark.waiting('waiting input');
30
+ case 'idle': return mark.idle();
31
+ case 'suspended': return mark.suspended();
32
+ case 'paused': return c.w('⏸ paused');
33
+ case 'stopped': return c.m('■ stopped');
34
+ default: return c.m(s.state);
35
+ }
36
+ }
37
+
38
+ export function decision(d) {
39
+ if (d === 'allowed') return c.g('✓ allowed');
40
+ if (d === 'recorded') return c.g('✓ recorded');
41
+ if (d === 'blocked') return c.r('✕ blocked');
42
+ if (d === 'held') return c.w('◐ held');
43
+ return c.m(d);
44
+ }
45
+
46
+ /**
47
+ * Columns: [{ key, label, w, fmt?(row) }]. Last column has no padding.
48
+ */
49
+ export function table(cols, rows, { indent = ' ' } = {}) {
50
+ const head = cols.map((col, i) => (i === cols.length - 1 ? col.label : pad(col.label, col.w))).join('');
51
+ line(indent + h(head));
52
+ for (const row of rows) {
53
+ const cells = cols.map((col, i) => {
54
+ const v = col.fmt ? col.fmt(row) : String(row[col.key] ?? '');
55
+ return i === cols.length - 1 ? v : pad(v, col.w);
56
+ });
57
+ line(indent + cells.join('').replace(/\s+$/, ''));
58
+ }
59
+ }
60
+
61
+ // Bordered box (blocked pushes etc). tone: 'r' | 'w' | 'g'
62
+ export function box(lines, tone = 'r') {
63
+ const col = c[tone];
64
+ const inner = Math.max(...lines.map((l) => width(l))) + 2;
65
+ line(col(`╭${'─'.repeat(inner)}╮`));
66
+ for (const l of lines) line(`${col('│')} ${pad(l, inner - 2)} ${col('│')}`);
67
+ line(col(`╰${'─'.repeat(inner)}╯`));
68
+ }
69
+
70
+ export function heading(text) { line(h(text)); }
71
+
72
+ export function json(data) {
73
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
74
+ }
75
+
76
+ export function csv(rows, keys) {
77
+ const esc = (v) => {
78
+ const s = v === undefined || v === null ? '' : String(v);
79
+ return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
80
+ };
81
+ process.stdout.write(`${keys.join(',')}\n`);
82
+ for (const r of rows) process.stdout.write(`${keys.map((k) => esc(r[k])).join(',')}\n`);
83
+ }
84
+
85
+ export { c, h, pad, width, strip, colorState };