incremnt 0.1.18 → 0.2.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.
package/src/browse.js ADDED
@@ -0,0 +1,1103 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+ import chalk from 'chalk';
3
+ import { Box, Text, render, useApp, useInput, useStdout } from 'ink';
4
+ import { formatPretty } from './format.js';
5
+
6
+ const UI = {
7
+ sectionWidth: 26,
8
+ itemWidth: 38
9
+ };
10
+
11
+ const COLORS = {
12
+ brand: chalk.hex('#5EEAD4'),
13
+ accent: chalk.hex('#60A5FA'),
14
+ focus: chalk.hex('#4ADE80'),
15
+ subtle: chalk.hex('#94A3B8')
16
+ };
17
+
18
+ export async function runBrowseCli({ transport, stdout, stderr, options = {} }) {
19
+ if (!(stdout.isTTY ?? false) || !(process.stdin.isTTY ?? false)) {
20
+ stderr.write('browse requires an interactive TTY.\n');
21
+ return 1;
22
+ }
23
+
24
+ try {
25
+ const data = await loadBrowseData(transport, options);
26
+ const app = render(
27
+ React.createElement(BrowseApp, {
28
+ transport,
29
+ data,
30
+ options
31
+ }),
32
+ {
33
+ stdout,
34
+ stdin: process.stdin,
35
+ stderr,
36
+ exitOnCtrlC: true
37
+ }
38
+ );
39
+
40
+ await app.waitUntilExit();
41
+ return 0;
42
+ } catch (error) {
43
+ stderr.write(`${error.message}\n`);
44
+ return 1;
45
+ }
46
+ }
47
+
48
+ async function loadBrowseData(transport, options) {
49
+ const requests = [
50
+ { key: 'sessions', command: 'session-insights', options: { limit: options.limit ?? '12' }, fallback: [] },
51
+ { key: 'programs', command: 'program-list', options: {}, fallback: [] },
52
+ { key: 'records', command: 'records', options: {}, fallback: [] },
53
+ { key: 'goals', command: 'goals-list', options: {}, fallback: [] },
54
+ { key: 'cycles', command: 'cycle-summary-list', options: {}, fallback: [] },
55
+ { key: 'programSummary', command: 'program-summary', options: {}, fallback: null },
56
+ { key: 'trainingLoad', command: 'training-load', options: {}, fallback: null },
57
+ { key: 'healthSummary', command: 'health-summary', options: { days: options.days ?? '14' }, fallback: null }
58
+ ];
59
+
60
+ const settled = await Promise.allSettled(
61
+ requests.map((request) => transport.executeReadCommand(request.command, request.options))
62
+ );
63
+
64
+ const result = {};
65
+ const loadErrors = [];
66
+
67
+ for (let index = 0; index < requests.length; index += 1) {
68
+ const request = requests[index];
69
+ const state = settled[index];
70
+
71
+ if (state.status === 'fulfilled') {
72
+ result[request.key] = state.value;
73
+ continue;
74
+ }
75
+
76
+ result[request.key] = request.fallback;
77
+ loadErrors.push({
78
+ command: request.command,
79
+ message: state.reason?.message ?? String(state.reason)
80
+ });
81
+ }
82
+
83
+ return {
84
+ ...result,
85
+ loadErrors
86
+ };
87
+ }
88
+
89
+ function BrowseApp({ transport, data, options }) {
90
+ const sections = useMemo(() => buildSections({ transport, data, options }), [transport, data, options]);
91
+ const initial = useMemo(() => resolveInitialFocus(sections, options), [sections, options]);
92
+ const [mode, setMode] = useState('NORMAL');
93
+ const [focusPane, setFocusPane] = useState('sections');
94
+ const [searchQuery, setSearchQuery] = useState('');
95
+ const [searchTargetPane, setSearchTargetPane] = useState('items');
96
+ const [sectionIndex, setSectionIndex] = useState(initial.sectionIndex);
97
+ const [itemIndicesBySection, setItemIndicesBySection] = useState(() => ({ [initial.sectionIndex]: initial.itemIndex }));
98
+ const [sectionViewportStart, setSectionViewportStart] = useState(0);
99
+ const [itemViewportStartsBySection, setItemViewportStartsBySection] = useState({});
100
+ const [detailViewportStart, setDetailViewportStart] = useState(0);
101
+ const [detailNonce, setDetailNonce] = useState(0);
102
+ const [detail, setDetail] = useState({ title: 'Details', body: 'Select an item.' });
103
+ const detailCacheRef = useRef(new Map());
104
+ const detailSnapshotRef = useRef(detail);
105
+ const launchTimeRef = useRef(new Date().toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }));
106
+ const { stdout } = useStdout();
107
+ const { exit } = useApp();
108
+ const rows = stdout.rows || 24;
109
+ const listViewportSize = Math.max(4, rows - 9);
110
+
111
+ const sectionRows = useMemo(
112
+ () => filterSectionRows(sections, searchTargetPane === 'sections' ? searchQuery : ''),
113
+ [sections, searchQuery, searchTargetPane]
114
+ );
115
+ const selectedSectionRow = sectionRows.findIndex((row) => row.index === sectionIndex);
116
+ const section = sections[sectionIndex] ?? sections[0];
117
+ const activeItems = section?.items ?? [];
118
+ const itemRows = useMemo(
119
+ () => filterItemRows(activeItems, searchTargetPane === 'items' ? searchQuery : ''),
120
+ [activeItems, searchQuery, searchTargetPane]
121
+ );
122
+
123
+ const rawItemIndex = itemIndicesBySection[sectionIndex];
124
+ const hasItemSelection = Number.isInteger(rawItemIndex);
125
+ const itemIndex = hasItemSelection ? clampIndex(rawItemIndex, activeItems.length) : 0;
126
+ const selectedItemRow = hasItemSelection ? itemRows.findIndex((row) => row.index === itemIndex) : -1;
127
+ const selectedItemVisible = selectedItemRow >= 0;
128
+ const canResolveDetail = hasItemSelection
129
+ && focusPane !== 'sections'
130
+ && (searchTargetPane !== 'items' || searchQuery.length === 0 || selectedItemVisible);
131
+ const detailLines = String(detail.body ?? '').split('\n');
132
+
133
+ const setActiveItemIndex = (next) => {
134
+ setItemIndicesBySection((current) => {
135
+ const prev = current[sectionIndex] ?? 0;
136
+ const resolved = typeof next === 'function' ? next(prev) : next;
137
+ return {
138
+ ...current,
139
+ [sectionIndex]: resolved
140
+ };
141
+ });
142
+ };
143
+
144
+ const moveFocusPane = (direction) => {
145
+ const nextPane = cycleFocusPane(focusPane, direction);
146
+ if ((nextPane === 'items' || nextPane === 'detail') && itemRows.length > 0 && (!hasItemSelection || !selectedItemVisible)) {
147
+ setActiveItemIndex(itemRows[0].index);
148
+ }
149
+ setFocusPane(nextPane);
150
+ };
151
+
152
+ useEffect(() => {
153
+ const focusIndex = selectedSectionRow >= 0 ? selectedSectionRow : 0;
154
+ setSectionViewportStart((current) => adjustViewportStart(current, focusIndex, sectionRows.length, listViewportSize));
155
+ }, [sectionRows.length, selectedSectionRow, listViewportSize]);
156
+
157
+ useEffect(() => {
158
+ const focusIndex = selectedItemRow >= 0 ? selectedItemRow : 0;
159
+ const itemCount = itemRows.length;
160
+ setItemViewportStartsBySection((current) => ({
161
+ ...current,
162
+ [sectionIndex]: adjustViewportStart(current[sectionIndex] ?? 0, focusIndex, itemCount, listViewportSize)
163
+ }));
164
+ }, [sectionIndex, selectedItemRow, itemRows.length, listViewportSize]);
165
+
166
+ useEffect(() => {
167
+ const setDetailIfChanged = (next) => {
168
+ if (detailSnapshotRef.current.title === next.title && detailSnapshotRef.current.body === next.body) {
169
+ return;
170
+ }
171
+ detailSnapshotRef.current = next;
172
+ setDetail(next);
173
+ };
174
+
175
+ const item = canResolveDetail ? section?.items?.[itemIndex] : null;
176
+
177
+ if (!section) {
178
+ setDetailIfChanged({ title: 'Details', body: 'No section selected.' });
179
+ return undefined;
180
+ }
181
+
182
+ if (!item) {
183
+ setDetailIfChanged({
184
+ title: focusPane === 'detail' ? 'Detail' : section.title,
185
+ body: chalk.dim('Select an item with ↑/↓ to load details.')
186
+ });
187
+ return undefined;
188
+ }
189
+
190
+ let cancelled = false;
191
+ const detailTitle = item.detailTitle ?? item.label;
192
+ const detailKey = `${section.id}:${item.shortcut ?? item.label}`;
193
+ const cached = detailCacheRef.current.get(detailKey);
194
+
195
+ if (cached && detailNonce === 0) {
196
+ setDetailIfChanged({
197
+ title: detailTitle,
198
+ body: cached
199
+ });
200
+ return undefined;
201
+ }
202
+
203
+ setDetailIfChanged({
204
+ title: detailTitle,
205
+ body: chalk.dim('Loading...')
206
+ });
207
+
208
+ Promise.resolve()
209
+ .then(() => item.loadDetail())
210
+ .then((body) => {
211
+ if (!cancelled) {
212
+ detailCacheRef.current.set(detailKey, body);
213
+ setDetailIfChanged({
214
+ title: detailTitle,
215
+ body
216
+ });
217
+ if (detailNonce !== 0) {
218
+ setDetailNonce(0);
219
+ }
220
+ }
221
+ })
222
+ .catch((error) => {
223
+ if (!cancelled) {
224
+ setDetailIfChanged({
225
+ title: detailTitle,
226
+ body: `Failed to load details: ${error.message}`
227
+ });
228
+ if (detailNonce !== 0) {
229
+ setDetailNonce(0);
230
+ }
231
+ }
232
+ });
233
+
234
+ return () => {
235
+ cancelled = true;
236
+ };
237
+ }, [section, sectionIndex, itemIndex, detailNonce, canResolveDetail, focusPane]);
238
+
239
+ useEffect(() => {
240
+ setDetailViewportStart(0);
241
+ }, [detail.title, detail.body]);
242
+
243
+ useInput((input, key) => {
244
+ if (input === 'q' || (key.ctrl && input === 'c')) {
245
+ exit();
246
+ return;
247
+ }
248
+
249
+ if (mode === 'SEARCH') {
250
+ if (key.escape || key.return) {
251
+ setMode('NORMAL');
252
+ return;
253
+ }
254
+
255
+ if (key.ctrl && input === 'u') {
256
+ setSearchQuery('');
257
+ return;
258
+ }
259
+
260
+ if (key.ctrl && input === 'l') {
261
+ setSearchQuery('');
262
+ setMode('NORMAL');
263
+ return;
264
+ }
265
+
266
+ if (key.backspace || key.delete) {
267
+ setSearchQuery((current) => current.slice(0, -1));
268
+ return;
269
+ }
270
+
271
+ if (input && input.length === 1 && !key.ctrl && !key.meta && !key.tab) {
272
+ setSearchQuery((current) => `${current}${input}`);
273
+ }
274
+ return;
275
+ }
276
+
277
+ if (key.escape) {
278
+ if (focusPane === 'detail') {
279
+ moveFocusPane(-1);
280
+ } else if (focusPane === 'items') {
281
+ moveFocusPane(-1);
282
+ }
283
+ return;
284
+ }
285
+
286
+ if (input === '/') {
287
+ const targetPane = focusPane === 'sections' ? 'sections' : 'items';
288
+ setSearchTargetPane(targetPane);
289
+ setMode('SEARCH');
290
+ return;
291
+ }
292
+
293
+ if (input === '1') {
294
+ setFocusPane('sections');
295
+ return;
296
+ }
297
+
298
+ if (input === '2') {
299
+ setFocusPane('items');
300
+ return;
301
+ }
302
+
303
+ if (input === '3') {
304
+ setFocusPane('detail');
305
+ return;
306
+ }
307
+
308
+ if (input === 'r') {
309
+ setDetailNonce((current) => current + 1);
310
+ return;
311
+ }
312
+
313
+ if (key.tab || (key.shift && key.tab)) {
314
+ moveFocusPane(key.shift ? -1 : 1);
315
+ return;
316
+ }
317
+
318
+ if (key.leftArrow || input === 'h') {
319
+ moveFocusPane(-1);
320
+ return;
321
+ }
322
+
323
+ if (key.rightArrow || input === 'l') {
324
+ moveFocusPane(1);
325
+ return;
326
+ }
327
+
328
+ if (key.return) {
329
+ if (focusPane === 'sections') {
330
+ moveFocusPane(1);
331
+ } else if (focusPane === 'items') {
332
+ if (!hasItemSelection && itemRows.length > 0) {
333
+ setActiveItemIndex(itemRows[0].index);
334
+ } else {
335
+ setDetailNonce((current) => current + 1);
336
+ }
337
+ }
338
+ return;
339
+ }
340
+
341
+ if (focusPane === 'sections' && (key.upArrow || input === 'k')) {
342
+ const nextRow = nextRowIndex(selectedSectionRow, sectionRows.length, -1);
343
+ if (nextRow >= 0) setSectionIndex(sectionRows[nextRow].index);
344
+ return;
345
+ }
346
+
347
+ if (focusPane === 'sections' && (key.downArrow || input === 'j')) {
348
+ const nextRow = nextRowIndex(selectedSectionRow, sectionRows.length, 1);
349
+ if (nextRow >= 0) setSectionIndex(sectionRows[nextRow].index);
350
+ return;
351
+ }
352
+
353
+ if (focusPane === 'sections' && (key.home || input === 'g')) {
354
+ if (sectionRows.length > 0) setSectionIndex(sectionRows[0].index);
355
+ return;
356
+ }
357
+
358
+ if (focusPane === 'sections' && (key.end || input === 'G' || (key.shift && input === 'g'))) {
359
+ if (sectionRows.length > 0) setSectionIndex(sectionRows[sectionRows.length - 1].index);
360
+ return;
361
+ }
362
+
363
+ if (focusPane === 'sections' && key.pageUp) {
364
+ if (sectionRows.length > 0) {
365
+ const jump = Math.max(1, Math.floor((stdout.rows || 24) / 3));
366
+ const current = selectedSectionRow >= 0 ? selectedSectionRow : 0;
367
+ const next = clampIndex(current - jump, sectionRows.length);
368
+ setSectionIndex(sectionRows[next].index);
369
+ }
370
+ return;
371
+ }
372
+
373
+ if (focusPane === 'sections' && key.pageDown) {
374
+ if (sectionRows.length > 0) {
375
+ const jump = Math.max(1, Math.floor((stdout.rows || 24) / 3));
376
+ const current = selectedSectionRow >= 0 ? selectedSectionRow : 0;
377
+ const next = clampIndex(current + jump, sectionRows.length);
378
+ setSectionIndex(sectionRows[next].index);
379
+ }
380
+ return;
381
+ }
382
+
383
+ if (focusPane === 'detail') {
384
+ if (key.home || input === 'g') {
385
+ setDetailViewportStart(0);
386
+ return;
387
+ }
388
+
389
+ if (key.end || input === 'G' || (key.shift && input === 'g')) {
390
+ setDetailViewportStart(Math.max(0, detailLines.length - listViewportSize));
391
+ return;
392
+ }
393
+
394
+ if (key.pageUp) {
395
+ const jump = Math.max(1, Math.floor((stdout.rows || 24) / 3));
396
+ setDetailViewportStart((current) => clampIndex(current - jump, detailLines.length));
397
+ return;
398
+ }
399
+
400
+ if (key.pageDown) {
401
+ const jump = Math.max(1, Math.floor((stdout.rows || 24) / 3));
402
+ setDetailViewportStart((current) => clampIndex(current + jump, detailLines.length));
403
+ return;
404
+ }
405
+
406
+ if (key.upArrow || input === 'k') {
407
+ setDetailViewportStart((current) => clampIndex(current - 1, detailLines.length));
408
+ return;
409
+ }
410
+
411
+ if (key.downArrow || input === 'j') {
412
+ setDetailViewportStart((current) => clampIndex(current + 1, detailLines.length));
413
+ return;
414
+ }
415
+ }
416
+
417
+ if (focusPane !== 'items') {
418
+ return;
419
+ }
420
+
421
+ if (key.upArrow || input === 'k') {
422
+ const nextRow = nextRowIndex(selectedItemRow, itemRows.length, -1);
423
+ if (nextRow >= 0) setActiveItemIndex(itemRows[nextRow].index);
424
+ return;
425
+ }
426
+
427
+ if (key.downArrow || input === 'j') {
428
+ const nextRow = nextRowIndex(selectedItemRow, itemRows.length, 1);
429
+ if (nextRow >= 0) setActiveItemIndex(itemRows[nextRow].index);
430
+ return;
431
+ }
432
+
433
+ if (key.home) {
434
+ if (itemRows.length > 0) setActiveItemIndex(itemRows[0].index);
435
+ return;
436
+ }
437
+
438
+ if (input === 'g') {
439
+ if (itemRows.length > 0) setActiveItemIndex(itemRows[0].index);
440
+ return;
441
+ }
442
+
443
+ if (input === 'G' || (key.shift && input === 'g')) {
444
+ if (itemRows.length > 0) setActiveItemIndex(itemRows[itemRows.length - 1].index);
445
+ return;
446
+ }
447
+
448
+ if (key.pageUp) {
449
+ if (itemRows.length > 0) {
450
+ const jump = Math.max(1, Math.floor((stdout.rows || 24) / 3));
451
+ const current = selectedItemRow >= 0 ? selectedItemRow : 0;
452
+ const next = clampIndex(current - jump, itemRows.length);
453
+ setActiveItemIndex(itemRows[next].index);
454
+ }
455
+ return;
456
+ }
457
+
458
+ if (key.pageDown) {
459
+ if (itemRows.length > 0) {
460
+ const jump = Math.max(1, Math.floor((stdout.rows || 24) / 3));
461
+ const current = selectedItemRow >= 0 ? selectedItemRow : 0;
462
+ const next = clampIndex(current + jump, itemRows.length);
463
+ setActiveItemIndex(itemRows[next].index);
464
+ }
465
+ return;
466
+ }
467
+
468
+ if (key.end) {
469
+ if (itemRows.length > 0) setActiveItemIndex(itemRows[itemRows.length - 1].index);
470
+ }
471
+ });
472
+
473
+ const currentItem = hasItemSelection ? (activeItems[itemIndex] ?? null) : null;
474
+ const totalItems = activeItems.length;
475
+ const sectionWindow = sliceViewportFromStart(sectionRows, sectionViewportStart, listViewportSize);
476
+ const itemWindow = sliceViewportFromStart(itemRows, itemViewportStartsBySection[sectionIndex] ?? 0, listViewportSize);
477
+ const visibleSections = sectionWindow.items;
478
+ const visibleItems = itemWindow.items;
479
+ const detailWindow = sliceViewportFromStart(detailLines, detailViewportStart, listViewportSize);
480
+ const visibleDetailLines = detailWindow.items;
481
+ const sectionsOverflowTop = sectionWindow.start > 0;
482
+ const sectionsOverflowBottom = sectionWindow.start + visibleSections.length < sectionRows.length;
483
+ const itemsOverflowTop = itemWindow.start > 0 && itemRows.length > 0;
484
+ const itemsOverflowBottom = itemWindow.start + visibleItems.length < itemRows.length;
485
+ const detailOverflowTop = detailWindow.start > 0;
486
+ const detailOverflowBottom = detailWindow.start + visibleDetailLines.length < detailLines.length;
487
+ const detailRangeLabel = `${detailWindow.start + 1}-${Math.min(detailWindow.start + visibleDetailLines.length, Math.max(detailLines.length, 1))}/${Math.max(detailLines.length, 1)}`;
488
+ const helpLine = 'tab pane focus · 1/2/3 focus panes · enter open/select · / search · esc back · pgup/pgdn jump · q quit';
489
+
490
+ return React.createElement(
491
+ Box,
492
+ {
493
+ flexDirection: 'column',
494
+ height: rows,
495
+ overflow: 'hidden',
496
+ paddingX: 1,
497
+ paddingY: 0
498
+ },
499
+ React.createElement(
500
+ Text,
501
+ { key: 'title-compact', wrap: 'truncate-end' },
502
+ renderBrowseBrandWordmark() + COLORS.accent.bold(' browse') + COLORS.subtle(` ${launchTimeRef.current}`)
503
+ ),
504
+ React.createElement(
505
+ Text,
506
+ { wrap: 'truncate-end' },
507
+ COLORS.subtle(`${helpLine} · mode ${mode.toLowerCase()} · focus ${focusPane}${searchQuery ? ` · /${searchQuery}` : ''}`)
508
+ ),
509
+ mode === 'SEARCH'
510
+ ? React.createElement(
511
+ Text,
512
+ { wrap: 'truncate-end' },
513
+ COLORS.focus(`SEARCH ${searchTargetPane}: /${searchQuery}_`) + COLORS.subtle(' (Esc/Enter accept, Ctrl-U clear, Ctrl-L clear+exit)')
514
+ )
515
+ : null,
516
+ React.createElement(Box, { marginTop: 1, flexDirection: 'row', flexGrow: 1, overflow: 'hidden' },
517
+ React.createElement(Panel, {
518
+ title: `Sections${sectionsOverflowTop ? ' ↑' : ''}${sectionsOverflowBottom ? ' ↓' : ''}`,
519
+ width: UI.sectionWidth,
520
+ overflow: 'hidden',
521
+ titleColor: focusPane === 'sections' ? COLORS.focus : COLORS.accent,
522
+ bodyLines: visibleSections.map((row) => {
523
+ const entry = row.section;
524
+ const selected = row.index === sectionIndex;
525
+ const marker = selected ? COLORS.focus('▸') : COLORS.subtle('·');
526
+ const line = `${marker} ${entry.title}${entry.items.length ? COLORS.subtle(` (${entry.items.length})`) : ''}`;
527
+ const highlighted = focusPane === 'sections' ? chalk.inverse(line) : line;
528
+ return selected ? highlighted : line;
529
+ })
530
+ }),
531
+ React.createElement(Panel, {
532
+ title: totalItems > 0
533
+ ? `${section?.title ?? 'Items'} ${selectedItemRow >= 0 ? selectedItemRow + 1 : 0}/${Math.max(itemRows.length, 1)}${itemsOverflowTop ? ' ↑' : ''}${itemsOverflowBottom ? ' ↓' : ''}${itemRows.length !== totalItems ? ` · filter ${itemRows.length}/${totalItems}` : ''}`
534
+ : (section?.title ?? 'Items'),
535
+ width: UI.itemWidth,
536
+ overflow: 'hidden',
537
+ titleColor: focusPane === 'items' ? COLORS.focus : COLORS.accent,
538
+ bodyLines: itemRows.length === 0
539
+ ? [chalk.dim('No items.')]
540
+ : visibleItems.map((row) => {
541
+ const selected = hasItemSelection && row.index === itemIndex;
542
+ const marker = selected ? COLORS.focus('▸') : COLORS.subtle('·');
543
+ const label = selected ? chalk.bold(row.item.label) : row.item.label;
544
+ const meta = selected && row.item.meta ? COLORS.subtle(` ${row.item.meta}`) : '';
545
+ const line = `${marker} ${label}${meta}`;
546
+ const highlighted = selected && focusPane === 'items' ? chalk.inverse(line) : line;
547
+ return selected ? highlighted : line;
548
+ })
549
+ }),
550
+ React.createElement(Panel, {
551
+ title: `${detail.title} ${detailRangeLabel}${detailOverflowTop ? ' ↑' : ''}${detailOverflowBottom ? ' ↓' : ''}`,
552
+ width: 0,
553
+ grow: true,
554
+ overflow: 'hidden',
555
+ titleColor: focusPane === 'detail' ? COLORS.focus : COLORS.accent,
556
+ bodyWrap: 'truncate-end',
557
+ bodyLines: visibleDetailLines
558
+ })
559
+ ),
560
+ React.createElement(
561
+ Text,
562
+ { wrap: 'truncate-end' },
563
+ COLORS.subtle(
564
+ `Section ${sectionIndex + 1}/${sections.length} · Item ${hasItemSelection && totalItems ? itemIndex + 1 : 0}/${Math.max(totalItems, 1)}${currentItem?.shortcut ? ` · ${currentItem.shortcut}` : ''}${detailOverflowBottom ? ' · detail more ↓ (press 3 then j/k)' : ''}${data.loadErrors?.length ? ` · warnings ${data.loadErrors.length}` : ''}`
565
+ )
566
+ )
567
+ );
568
+ }
569
+
570
+ function Panel({ title, body, bodyLines = null, width = 0, grow = false, overflow = 'hidden', bodyWrap = 'wrap', titleColor = chalk.white }) {
571
+ const boxProps = {
572
+ flexDirection: 'column',
573
+ borderStyle: 'round',
574
+ borderColor: 'gray',
575
+ overflow,
576
+ paddingX: 1,
577
+ paddingY: 0
578
+ };
579
+
580
+ if (width > 0) {
581
+ boxProps.width = width;
582
+ }
583
+
584
+ if (grow) {
585
+ boxProps.flexGrow = 1;
586
+ }
587
+
588
+ return React.createElement(
589
+ Box,
590
+ boxProps,
591
+ React.createElement(Text, { bold: true, wrap: 'truncate-end' }, titleColor(title)),
592
+ React.createElement(
593
+ Box,
594
+ {
595
+ flexDirection: 'column',
596
+ flexGrow: 1,
597
+ overflow: 'hidden'
598
+ },
599
+ Array.isArray(bodyLines)
600
+ ? bodyLines.map((line, index) => React.createElement(Text, { key: `${title}-${index}`, wrap: 'truncate-end' }, line))
601
+ : React.createElement(Text, { wrap: bodyWrap }, body)
602
+ )
603
+ );
604
+ }
605
+
606
+ export function buildSections({ transport, data, options }) {
607
+ const sections = [
608
+ {
609
+ id: 'overview',
610
+ title: 'Overview',
611
+ items: ensureItems(buildOverviewItems({ transport, data }), 'No overview data')
612
+ },
613
+ {
614
+ id: 'sessions',
615
+ title: 'Sessions',
616
+ items: ensureItems((data.sessions ?? []).map((session) => ({
617
+ label: formatSessionLabel(session.sessionDate),
618
+ meta: null,
619
+ detailTitle: 'Session details',
620
+ shortcut: session.sessionId.slice(0, 8),
621
+ loadDetail: async () => {
622
+ const payload = await transport.executeReadCommand('session-show', { id: session.sessionId });
623
+ return formatBrowseSessionDetail(payload);
624
+ }
625
+ })), 'No sessions found')
626
+ },
627
+ {
628
+ id: 'programs',
629
+ title: 'Programs',
630
+ items: ensureItems((data.programs ?? []).map((program) => ({
631
+ label: program.programName,
632
+ meta: `${program.isActive ? 'active' : 'inactive'} · week ${program.currentWeek}`,
633
+ detailTitle: 'Program details',
634
+ shortcut: program.programId.slice(0, 8),
635
+ loadDetail: async () => {
636
+ const payload = await transport.executeReadCommand('program-detail', { id: program.programId });
637
+ return formatPretty('program-detail', payload) ?? JSON.stringify(payload, null, 2);
638
+ }
639
+ })), 'No programs found')
640
+ },
641
+ {
642
+ id: 'records',
643
+ title: 'Records',
644
+ items: ensureItems((data.records ?? []).map((record) => ({
645
+ label: record.exerciseName,
646
+ meta: null,
647
+ detailTitle: 'Exercise history',
648
+ shortcut: record.exerciseName,
649
+ loadDetail: async () => {
650
+ const payload = await transport.executeReadCommand('exercise-history', { name: record.exerciseName });
651
+ const summary = record.weight > 0
652
+ ? `${Number(record.weight).toFixed(1)} kg x ${record.reps}`
653
+ : `${record.reps} reps`;
654
+ const lines = [
655
+ chalk.bold(record.exerciseName.toUpperCase()),
656
+ `${chalk.dim('Record')} ${chalk.bold(summary)} ${chalk.dim(`· ${formatSessionLabel(record.sessionDate)}`)}`,
657
+ ''
658
+ ];
659
+ const formatted = formatPretty('exercise-history', payload) ?? JSON.stringify(payload, null, 2);
660
+ return `${lines.join('\n')}${formatted}`;
661
+ }
662
+ })), 'No records found')
663
+ },
664
+ {
665
+ id: 'goals',
666
+ title: 'Goals',
667
+ items: ensureItems((data.goals ?? []).map((goal) => ({
668
+ label: goal.planId,
669
+ meta: `${goal.status} · ${goal.goalCount} goals · ${goal.goalsWithData} with data`,
670
+ detailTitle: 'Goal details',
671
+ shortcut: goal.planId.slice(0, 8),
672
+ loadDetail: async () => {
673
+ const payload = await transport.executeReadCommand('goals-show', { id: goal.planId });
674
+ return formatPretty('goals-show', payload) ?? JSON.stringify(payload, null, 2);
675
+ }
676
+ })), 'No goals found')
677
+ },
678
+ {
679
+ id: 'cycles',
680
+ title: 'Cycles',
681
+ items: ensureItems((data.cycles ?? []).map((cycle) => ({
682
+ label: formatSessionLabel(cycle.completedDate),
683
+ meta: `${cycle.programName} · ${cycle.totalSetsCompleted}/${cycle.totalSetsPlanned} sets`,
684
+ detailTitle: 'Cycle summary',
685
+ shortcut: cycle.id.slice(0, 8),
686
+ loadDetail: async () => {
687
+ const payload = await transport.executeReadCommand('cycle-summary-show', { id: cycle.id });
688
+ return formatPretty('cycle-summary-show', payload) ?? JSON.stringify(payload, null, 2);
689
+ }
690
+ })), 'No cycles found')
691
+ },
692
+ {
693
+ id: 'health',
694
+ title: 'Health',
695
+ items: ensureItems(buildHealthItems(data), 'No health data found')
696
+ }
697
+ ];
698
+
699
+ const focusExercise = options.exercise ?? options.name ?? null;
700
+ if (focusExercise) {
701
+ sections.splice(4, 0, {
702
+ id: 'history',
703
+ title: 'History',
704
+ items: [{
705
+ label: focusExercise,
706
+ meta: 'focused exercise history',
707
+ detailTitle: 'Exercise history',
708
+ shortcut: focusExercise,
709
+ loadDetail: async () => {
710
+ const payload = await transport.executeReadCommand('exercise-history', { name: focusExercise });
711
+ return formatPretty('exercise-history', payload) ?? JSON.stringify(payload, null, 2);
712
+ }
713
+ }]
714
+ });
715
+ }
716
+
717
+ return sections;
718
+ }
719
+
720
+ function buildOverviewItems({ transport, data }) {
721
+ const items = [];
722
+
723
+ if (data.programSummary) {
724
+ items.push({
725
+ label: data.programSummary.programName ?? 'No active program',
726
+ meta: `week ${data.programSummary.currentWeek ?? '?'} · next ${data.programSummary.currentDayTitle ?? 'n/a'}`,
727
+ detailTitle: 'Current program',
728
+ shortcut: 'program',
729
+ loadDetail: async () => formatPretty('program-summary', data.programSummary) ?? 'No active program.'
730
+ });
731
+ }
732
+
733
+ const recentSession = data.sessions?.[0];
734
+ if (recentSession) {
735
+ items.push({
736
+ label: formatSessionLabel(recentSession.sessionDate),
737
+ meta: `${recentSession.dayName ?? 'Workout'} · ${recentSession.exerciseCount ?? 0} exercises`,
738
+ detailTitle: 'Latest session',
739
+ shortcut: recentSession.sessionId.slice(0, 8),
740
+ loadDetail: async () => {
741
+ const payload = await transport.executeReadCommand('session-show', { id: recentSession.sessionId });
742
+ return formatBrowseSessionDetail(payload);
743
+ }
744
+ });
745
+ }
746
+
747
+ if (data.trainingLoad) {
748
+ items.push({
749
+ label: `Training load: ${data.trainingLoad.status}`,
750
+ meta: `${data.trainingLoad.last7Days?.avgPerDay ?? 0} vs ${data.trainingLoad.last28Days?.avgPerDay ?? 0}`,
751
+ detailTitle: 'Training load',
752
+ shortcut: 'load',
753
+ loadDetail: async () => formatTrainingLoad(data.trainingLoad)
754
+ });
755
+ }
756
+
757
+ if (data.healthSummary) {
758
+ items.push({
759
+ label: 'Health summary',
760
+ meta: data.healthSummary.available ? `last ${data.healthSummary.days} days` : 'no health data',
761
+ detailTitle: 'Health summary',
762
+ shortcut: 'health',
763
+ loadDetail: async () => formatHealthSummary(data.healthSummary)
764
+ });
765
+ }
766
+
767
+ if ((data.loadErrors?.length ?? 0) > 0) {
768
+ items.push({
769
+ label: `Data warnings (${data.loadErrors.length})`,
770
+ meta: 'some sections failed to load',
771
+ detailTitle: 'Load warnings',
772
+ shortcut: 'warnings',
773
+ loadDetail: async () => formatLoadWarnings(data.loadErrors)
774
+ });
775
+ }
776
+
777
+ return items;
778
+ }
779
+
780
+ function buildHealthItems(data) {
781
+ const items = [];
782
+
783
+ if (data.trainingLoad) {
784
+ items.push({
785
+ label: `Training load: ${data.trainingLoad.status}`,
786
+ meta: data.trainingLoad.statusDescription,
787
+ detailTitle: 'Training load',
788
+ shortcut: 'load',
789
+ loadDetail: async () => formatTrainingLoad(data.trainingLoad)
790
+ });
791
+ }
792
+
793
+ if (data.healthSummary) {
794
+ items.push({
795
+ label: 'Health summary',
796
+ meta: data.healthSummary.available ? `last ${data.healthSummary.days} days` : 'no health data',
797
+ detailTitle: 'Health summary',
798
+ shortcut: 'health',
799
+ loadDetail: async () => formatHealthSummary(data.healthSummary)
800
+ });
801
+ }
802
+
803
+ if (items.length === 0) {
804
+ items.push({
805
+ label: 'No health data',
806
+ meta: 'nothing synced yet',
807
+ detailTitle: 'Health summary',
808
+ shortcut: 'health',
809
+ loadDetail: async () => 'No health data found.'
810
+ });
811
+ }
812
+
813
+ return items;
814
+ }
815
+
816
+ function ensureItems(items, emptyLabel) {
817
+ if (items.length > 0) {
818
+ return items;
819
+ }
820
+
821
+ return [{
822
+ label: emptyLabel,
823
+ meta: 'empty snapshot',
824
+ detailTitle: emptyLabel,
825
+ shortcut: emptyLabel,
826
+ loadDetail: async () => emptyLabel
827
+ }];
828
+ }
829
+
830
+ function formatSessionLabel(sessionDate) {
831
+ const date = new Date(sessionDate);
832
+ if (Number.isNaN(date.getTime())) {
833
+ return String(sessionDate);
834
+ }
835
+
836
+ return new Intl.DateTimeFormat('en-GB', {
837
+ weekday: 'short',
838
+ day: 'numeric',
839
+ month: 'short'
840
+ }).format(date);
841
+ }
842
+
843
+ function sliceViewportFromStart(items, start, viewportSize) {
844
+ const maxStart = Math.max(0, items.length - viewportSize);
845
+ const clampedStart = Math.min(Math.max(start, 0), maxStart);
846
+ return {
847
+ items: items.slice(clampedStart, clampedStart + viewportSize),
848
+ start: clampedStart
849
+ };
850
+ }
851
+
852
+ export function filterSectionRows(sections, query) {
853
+ const normalized = String(query ?? '').trim().toLowerCase();
854
+ const rows = sections.map((section, index) => ({ section, index }));
855
+ if (!normalized) return rows;
856
+
857
+ return rows.filter((row) => textMatches(row.section.title, normalized));
858
+ }
859
+
860
+ export function filterItemRows(items, query) {
861
+ const normalized = String(query ?? '').trim().toLowerCase();
862
+ const rows = items.map((item, index) => ({ item, index }));
863
+ if (!normalized) return rows;
864
+
865
+ return rows.filter((row) => textMatches(`${row.item.label} ${row.item.meta ?? ''}`, normalized));
866
+ }
867
+
868
+ export function nextRowIndex(currentIndex, length, direction) {
869
+ if (length <= 0) return -1;
870
+ if (!Number.isInteger(currentIndex) || currentIndex < 0 || currentIndex >= length) {
871
+ return direction >= 0 ? 0 : length - 1;
872
+ }
873
+
874
+ return clampIndex(currentIndex + direction, length);
875
+ }
876
+
877
+ export function cycleFocusPane(currentPane, direction) {
878
+ const panes = ['sections', 'items', 'detail'];
879
+ const currentIndex = Math.max(0, panes.indexOf(currentPane));
880
+ const next = (currentIndex + direction + panes.length) % panes.length;
881
+ return panes[next];
882
+ }
883
+
884
+ function textMatches(value, query) {
885
+ return String(value ?? '').toLowerCase().includes(query);
886
+ }
887
+
888
+ function renderBrowseBrandWordmark() {
889
+ const text = 'INCREMNT';
890
+ const blendStart = 5; // start blending around the M like web/logo styling
891
+ const max = Math.max(1, text.length - blendStart - 1);
892
+ let out = '';
893
+
894
+ for (let index = 0; index < text.length; index += 1) {
895
+ const char = text[index];
896
+ if (index < blendStart) {
897
+ out += chalk.white.bold(char);
898
+ continue;
899
+ }
900
+
901
+ const factor = (index - blendStart) / max;
902
+ const r = Math.round(94 + (96 - 94) * factor);
903
+ const g = Math.round(234 + (165 - 234) * factor);
904
+ const b = Math.round(212 + (250 - 212) * factor);
905
+ out += chalk.rgb(r, g, b).bold(char);
906
+ }
907
+
908
+ return out;
909
+ }
910
+
911
+ function formatBrowseSessionDetail(payload) {
912
+ const base = formatPretty('session-show', payload) ?? JSON.stringify(payload, null, 2);
913
+ if (!payload || !Array.isArray(payload.exercises) || payload.exercises.length === 0) {
914
+ return base;
915
+ }
916
+
917
+ const lines = [base, '', chalk.bold('WORKOUT DETAIL'), ''];
918
+ for (const exercise of payload.exercises) {
919
+ lines.push(` ${chalk.bold(exercise.name ?? 'Exercise')}`);
920
+
921
+ const sets = Array.isArray(exercise.sets) ? exercise.sets : [];
922
+ if (sets.length === 0) {
923
+ lines.push(` ${chalk.dim('No completed sets recorded')}`);
924
+ lines.push('');
925
+ continue;
926
+ }
927
+
928
+ for (let index = 0; index < sets.length; index += 1) {
929
+ const set = sets[index] ?? {};
930
+ const reps = Number.isFinite(set.reps) ? set.reps : '?';
931
+ const weight = Number.isFinite(set.weight) ? Number(set.weight) : 0;
932
+ const weighted = weight > 0;
933
+ const setLabel = `S${index + 1}`.padEnd(3);
934
+ const main = weighted
935
+ ? `${weight.toFixed(1)} kg × ${reps}`
936
+ : `BW × ${reps}`;
937
+ const e1rm = weighted && Number.isFinite(set.reps)
938
+ ? ` · e1RM ${estimateOneRM(weight, set.reps).toFixed(1)}`
939
+ : '';
940
+ const rpe = Number.isFinite(set.rpe) ? ` · RPE ${set.rpe}` : '';
941
+ lines.push(` ${chalk.dim(setLabel)} ${chalk.bold(main)}${chalk.dim(e1rm)}${chalk.dim(rpe)}`);
942
+ }
943
+
944
+ lines.push('');
945
+ }
946
+
947
+ if (lines.at(-1) === '') lines.pop();
948
+ return lines.join('\n');
949
+ }
950
+
951
+ function estimateOneRM(weight, reps) {
952
+ return weight * (1 + (reps / 30));
953
+ }
954
+
955
+ export function resolveInitialFocus(sections, options) {
956
+ const focusExercise = options.exercise ?? options.name ?? null;
957
+ if (focusExercise) {
958
+ const index = sections.findIndex((section) => section.id === 'history');
959
+ return {
960
+ sectionIndex: index >= 0 ? index : 0,
961
+ itemIndex: 0
962
+ };
963
+ }
964
+
965
+ const explicitFocus = [
966
+ { optionKey: 'session-id', sectionId: 'sessions' },
967
+ { optionKey: 'program-id', sectionId: 'programs' },
968
+ { optionKey: 'goal-id', sectionId: 'goals' },
969
+ { optionKey: 'cycle-id', sectionId: 'cycles' }
970
+ ];
971
+
972
+ for (const focus of explicitFocus) {
973
+ const focusId = options[focus.optionKey];
974
+ if (!focusId) continue;
975
+
976
+ const sectionIndex = sections.findIndex((section) => section.id === focus.sectionId);
977
+ if (sectionIndex < 0) continue;
978
+
979
+ const itemIndex = sections[sectionIndex].items.findIndex((item) => (
980
+ item.shortcut === focusId || item.shortcut === focusId.slice(0, 8)
981
+ ));
982
+ if (itemIndex >= 0) {
983
+ return { sectionIndex, itemIndex };
984
+ }
985
+ }
986
+
987
+ return { sectionIndex: 0, itemIndex: 0 };
988
+ }
989
+
990
+ function formatTrainingLoad(payload) {
991
+ if (!payload?.available) {
992
+ return 'No training load data found.';
993
+ }
994
+
995
+ const lines = [];
996
+ lines.push(chalk.bold('TRAINING LOAD'));
997
+ lines.push('');
998
+ lines.push(kv('Status', payload.status));
999
+ lines.push(kv('Coverage', `${payload.coverageDays} days`));
1000
+ lines.push(kv('7-day avg', String(payload.last7Days?.avgPerDay ?? 0)));
1001
+ lines.push(kv('28-day avg', String(payload.last28Days?.avgPerDay ?? 0)));
1002
+ lines.push(kv('Readiness', `ATL ${payload.readiness?.atl ?? '?'} / CTL ${payload.readiness?.ctl ?? '?'} / TSB ${payload.readiness?.tsb ?? '?'}`));
1003
+ lines.push('');
1004
+ lines.push(payload.statusDescription ?? '');
1005
+ lines.push('');
1006
+ lines.push('Recent workouts:');
1007
+ for (const workout of payload.recentWorkouts?.slice(0, 8) ?? []) {
1008
+ lines.push(` ${workout.date} ${workout.type} effort ${workout.estimatedEffort ?? '?'}${workout.durationMins ? ` ${workout.durationMins} min` : ''}`);
1009
+ }
1010
+ return lines.filter(Boolean).join('\n');
1011
+ }
1012
+
1013
+ function formatHealthSummary(payload) {
1014
+ if (!payload?.available) {
1015
+ return 'No health data found.';
1016
+ }
1017
+
1018
+ const lines = [];
1019
+ lines.push(chalk.bold('HEALTH SUMMARY'));
1020
+ lines.push('');
1021
+ lines.push(kv('Days', String(payload.days ?? 14)));
1022
+
1023
+ if (payload.restingHR?.avg != null) {
1024
+ lines.push(kv('Resting HR', `${payload.restingHR.avg} bpm`));
1025
+ }
1026
+
1027
+ if (payload.hrv?.avg != null) {
1028
+ lines.push(kv('HRV', `${payload.hrv.avg} ms`));
1029
+ }
1030
+
1031
+ if (payload.vo2Max?.latest?.value != null) {
1032
+ lines.push(kv('VO2 max', String(payload.vo2Max.latest.value)));
1033
+ }
1034
+
1035
+ if (payload.sleep?.avgHours != null) {
1036
+ lines.push(kv('Sleep', `${payload.sleep.avgHours} h`));
1037
+ }
1038
+
1039
+ if (payload.bodyWeight?.latest?.value != null) {
1040
+ lines.push(kv('Body weight', `${payload.bodyWeight.latest.value} kg`));
1041
+ }
1042
+
1043
+ if (payload.respiratoryRate?.avg != null) {
1044
+ lines.push(kv('Resp. rate', String(payload.respiratoryRate.avg)));
1045
+ }
1046
+
1047
+ if (payload.bodyTemperature?.avg != null) {
1048
+ lines.push(kv('Temp', String(payload.bodyTemperature.avg)));
1049
+ }
1050
+
1051
+ if (payload.trainingLoad) {
1052
+ lines.push('');
1053
+ lines.push('Training load:');
1054
+ lines.push(` ${payload.trainingLoad.status}`);
1055
+ lines.push(` 7d avg ${payload.trainingLoad.last7Days?.avgPerDay ?? 0} / 28d avg ${payload.trainingLoad.last28Days?.avgPerDay ?? 0}`);
1056
+ }
1057
+
1058
+ return lines.join('\n');
1059
+ }
1060
+
1061
+ function kv(label, value) {
1062
+ return `${chalk.dim(label.padEnd(14))} ${chalk.bold(value)}`;
1063
+ }
1064
+
1065
+ function formatLoadWarnings(errors) {
1066
+ const lines = [
1067
+ chalk.bold('LOAD WARNINGS'),
1068
+ ''
1069
+ ];
1070
+
1071
+ for (const error of errors) {
1072
+ lines.push(`${chalk.dim(error.command)}: ${error.message}`);
1073
+ }
1074
+
1075
+ return lines.join('\n');
1076
+ }
1077
+
1078
+ function clampIndex(index, length) {
1079
+ if (length <= 0) {
1080
+ return 0;
1081
+ }
1082
+
1083
+ return Math.min(Math.max(index, 0), length - 1);
1084
+ }
1085
+
1086
+ function adjustViewportStart(currentStart, selectedIndex, totalItems, viewportSize) {
1087
+ if (totalItems <= viewportSize) {
1088
+ return 0;
1089
+ }
1090
+
1091
+ const maxStart = Math.max(0, totalItems - viewportSize);
1092
+ let start = Math.min(Math.max(currentStart, 0), maxStart);
1093
+ const topGuard = start + 1;
1094
+ const bottomGuard = start + viewportSize - 2;
1095
+
1096
+ if (selectedIndex < topGuard) {
1097
+ start = Math.max(0, selectedIndex - 1);
1098
+ } else if (selectedIndex > bottomGuard) {
1099
+ start = Math.min(maxStart, selectedIndex - viewportSize + 2);
1100
+ }
1101
+
1102
+ return start;
1103
+ }