reslop 0.2.1 → 0.2.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased][unreleased]
4
4
 
5
+ ## [0.2.3][] - 2026-09-27
6
+
7
+ - Add brief and full commit lists, toggled with `v`
8
+ - Select a commit from the list to view its diff
9
+ - Edit a multi-line commit message
10
+ - Propose unused removals only for libraries that export code
11
+ - Improve TUI/TUX, multiple small fixes
12
+ - Implement npm command runner with termination and re-run
13
+ - Filter tests output to reduce AI costs: `npm t` and other scripts
14
+ - Save script logs under `.log/`
15
+ - Group similar problems in test and other output
16
+
5
17
  ## [0.2.1][] - 2026-09-23
6
18
 
7
19
  - Type a new commit message at the top of the list
@@ -117,7 +129,8 @@
117
129
  - Feedback and todos that produce a repair plan for an agent
118
130
  - Intra-line highlighting in unified, mixed, and side-by-side layouts
119
131
 
120
- [unreleased]: https://github.com/tshemsedinov/reslop/compare/v0.2.1...HEAD
132
+ [unreleased]: https://github.com/tshemsedinov/reslop/compare/v0.2.3...HEAD
133
+ [0.2.3]: https://github.com/tshemsedinov/reslop/compare/v0.2.1...v0.2.3
121
134
  [0.2.1]: https://github.com/tshemsedinov/reslop/compare/v0.2.0...v0.2.1
122
135
  [0.2.0]: https://github.com/tshemsedinov/reslop/compare/v0.1.9...v0.2.0
123
136
  [0.1.9]: https://github.com/tshemsedinov/reslop/compare/v0.1.8...v0.1.9
package/README.md CHANGED
@@ -39,6 +39,7 @@ npm i -g reslop
39
39
  ```
40
40
 
41
41
  Works on Linux, macOS, and Windows. On Windows use Windows Terminal.
42
+ Requires Node.js `>=18.15.0`.
42
43
 
43
44
  ## Usage
44
45
 
@@ -49,6 +50,16 @@ Works on Linux, macOS, and Windows. On Windows use Windows Terminal.
49
50
  - `reslop -n` start a new review even if the latest is still editing
50
51
  - `reslop -r` read-only mode
51
52
  - `reslop -light` light color theme (default dark)
53
+ - `reslop t -- <program> [args]` run one program and print a reduced report
54
+
55
+ Prefix each program in an npm script with `reslop t --`. Keep `&&` between those wraps, so each program is captured on its own and a failing step does not start the next one:
56
+
57
+ ```json
58
+ "lint": "reslop t -- eslint . && reslop t -- prettier -c \"**/*.js\"",
59
+ "test": "reslop t -- npm run -s lint && reslop t -- node --test"
60
+ ```
61
+
62
+ The report is Markdown: failures, diagnostics, and a short summary. Passing results are omitted and similar problems are grouped. The same text is saved under `.log/`. Set `RESLOP_OUTPUT=raw` to pass the command through unchanged. The review screen runs scripts in raw mode and applies its own filter once.
52
63
 
53
64
  Reviews go in `.review/YYYY-MM-DD-NN.md` with frontmatter `status`:
54
65
 
@@ -65,7 +76,6 @@ Reviews go in `.review/YYYY-MM-DD-NN.md` with frontmatter `status`:
65
76
  - Send anonymized code blocks for expert review
66
77
  - Send questions and the repair plan to experts for approval
67
78
  - Ask experts
68
- - Verify the codebase
69
79
  - Apply refactoring skills
70
80
  - Call agents, harnesses, and IDEs to execute prepared plans
71
81
  - Squash, cherry-pick, and interactive rebase
package/lib/ansi.js CHANGED
@@ -60,7 +60,6 @@ const DARK_UI = {
60
60
  headerDirFg: [0x6e, 0x6e, 0x6e],
61
61
  headerSlashFg: [0x8a, 0x8a, 0x8a],
62
62
  headerFileFg: [0x4e, 0x4e, 0x4e],
63
- headerMetaFg: [0x5a, 0x5a, 0x5a],
64
63
  chromeFg: [0xd0, 0xd0, 0xe0],
65
64
  chromeBg: [0x18, 0x18, 0x24],
66
65
  noteBg: [0x24, 0x24, 0x36],
@@ -153,7 +152,6 @@ const LIGHT_UI = {
153
152
  headerDirFg: [0x57, 0x60, 0x6a],
154
153
  headerSlashFg: [0x8c, 0x95, 0x9f],
155
154
  headerFileFg: [0x1f, 0x23, 0x28],
156
- headerMetaFg: [0x42, 0x4a, 0x53],
157
155
  chromeFg: [0x24, 0x29, 0x2f],
158
156
  chromeBg: [0xf0, 0xf2, 0xf5],
159
157
  noteBg: [0xe8, 0xf0, 0xfe],
@@ -280,9 +278,14 @@ const codeFg = (style) => {
280
278
  return CODE_FG.plain;
281
279
  };
282
280
 
283
- const fg = (rgb) => `${ESC}[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`;
281
+ const channels = (rgb) => {
282
+ if (typeof rgb === 'number') return `${rgb};${rgb};${rgb}`;
283
+ return `${rgb[0]};${rgb[1]};${rgb[2]}`;
284
+ };
285
+
286
+ const fg = (rgb) => `${ESC}[38;2;${channels(rgb)}m`;
284
287
 
285
- const bg = (rgb) => `${ESC}[48;2;${rgb[0]};${rgb[1]};${rgb[2]}m`;
288
+ const bg = (rgb) => `${ESC}[48;2;${channels(rgb)}m`;
286
289
 
287
290
  const seq = (fgRgb, bgRgb) => `${fg(fgRgb)}${bg(bgRgb)}`;
288
291
 
@@ -352,6 +355,117 @@ const truncateVisible = (text, width) => {
352
355
  return out;
353
356
  };
354
357
 
358
+ const csiLength = (text, index) => {
359
+ if (text[index] !== ESC || text[index + 1] !== '[') return 0;
360
+ let at = index + 2;
361
+ while (at < text.length) {
362
+ const code = text.charCodeAt(at);
363
+ at += 1;
364
+ if (code >= 64 && code <= 126) return at - index;
365
+ }
366
+ return 0;
367
+ };
368
+
369
+ const firstGrapheme = (text) => {
370
+ const next = SEGMENTER.segment(text)[Symbol.iterator]().next();
371
+ if (next.done || !next.value) return '';
372
+ return next.value.segment;
373
+ };
374
+
375
+ const clipAnsi = (text, width) => {
376
+ const raw = `${text ?? ''}`;
377
+ if (width <= 0) return '';
378
+ if (visibleWidth(raw) <= width) return raw;
379
+ let used = 0;
380
+ let out = '';
381
+ let index = 0;
382
+ while (index < raw.length && used < width) {
383
+ const csi = csiLength(raw, index);
384
+ if (csi) {
385
+ out += raw.slice(index, index + csi);
386
+ index += csi;
387
+ continue;
388
+ }
389
+ const ch = firstGrapheme(raw.slice(index));
390
+ if (!ch) break;
391
+ const w = graphemeWidth(ch, used);
392
+ if (used + w > width) break;
393
+ out += ch;
394
+ used += w;
395
+ index += ch.length;
396
+ }
397
+ if (stripAnsi(out) !== stripAnsi(raw)) out += RESET;
398
+ return out;
399
+ };
400
+
401
+ const sgrParams = (seq) => {
402
+ const inner = seq.slice(2, -1);
403
+ if (!inner) return [0];
404
+ return inner.split(';').map((part) => (part === '' ? 0 : Number(part)));
405
+ };
406
+
407
+ const colorSpan = (params, index) => {
408
+ const mode = params[index + 1];
409
+ if (mode === 5) return 2;
410
+ if (mode === 2) return 4;
411
+ return 0;
412
+ };
413
+
414
+ const keepFgParams = (params) => {
415
+ const kept = [];
416
+ for (let i = 0; i < params.length; i++) {
417
+ const code = params[i];
418
+ if (code === 38 || code === 48) {
419
+ const extra = colorSpan(params, i);
420
+ if (code === 38 && extra) {
421
+ kept.push(code, ...params.slice(i + 1, i + 1 + extra));
422
+ }
423
+ i += extra;
424
+ continue;
425
+ }
426
+ if (code >= 40 && code <= 49) continue;
427
+ if (code >= 100 && code <= 107) continue;
428
+ if (code === 7 || code === 27) continue;
429
+ kept.push(code);
430
+ }
431
+ return kept;
432
+ };
433
+
434
+ const foregroundOn = (text, bgRgb) => {
435
+ const raw = `${text ?? ''}`;
436
+ const ground = bg(bgRgb);
437
+ let out = '';
438
+ let index = 0;
439
+ while (index < raw.length) {
440
+ if (raw[index] !== ESC) {
441
+ const next = raw.indexOf(ESC, index);
442
+ const end = next < 0 ? raw.length : next;
443
+ out += raw.slice(index, end);
444
+ index = end;
445
+ continue;
446
+ }
447
+ const csi = csiLength(raw, index);
448
+ if (!csi) {
449
+ out += raw[index];
450
+ index += 1;
451
+ continue;
452
+ }
453
+ const seq = raw.slice(index, index + csi);
454
+ index += csi;
455
+ if (!seq.endsWith('m')) {
456
+ out += seq;
457
+ continue;
458
+ }
459
+ const kept = keepFgParams(sgrParams(seq));
460
+ const reset = kept.includes(0);
461
+ const rest = kept.filter((code) => code !== 0);
462
+ if (reset) out += `${ESC}[0m`;
463
+ out += ground;
464
+ if (rest.length) out += `${ESC}[${rest.join(';')}m`;
465
+ }
466
+ return out;
467
+ };
468
+
355
469
  module.exports = {
356
470
  ESC,
357
471
  RESET,
@@ -373,4 +487,6 @@ module.exports = {
373
487
  graphemeWidth,
374
488
  visibleWidth,
375
489
  truncateVisible,
490
+ clipAnsi,
491
+ foregroundOn,
376
492
  };
package/lib/cli.js CHANGED
@@ -11,6 +11,7 @@ const { selectChangeSource, createLoadedSource } = require('./source.js');
11
11
  const { Session } = require('./session.js');
12
12
  const { capabilitiesFor, attachCapabilities } = require('./capabilities.js');
13
13
  const { setTheme } = require('./ansi.js');
14
+ const { runCapture } = require('./report-run.js');
14
15
 
15
16
  const HEX_REV = /^[0-9a-f]{4,40}$/i;
16
17
  const HEAD_REV = /^HEAD([~^].*)?$/i;
@@ -180,6 +181,8 @@ const runUi = async (session, proc) => {
180
181
  };
181
182
 
182
183
  const run = async (proc, deps = {}) => {
184
+ const argv = proc.argv.slice(2);
185
+ if (argv[0] === 't') return runCapture(proc, deps);
183
186
  const stdout = proc.stdout;
184
187
  const stderr = proc.stderr;
185
188
  let args;
package/lib/deps.js CHANGED
@@ -73,6 +73,46 @@ const pkgHasExportEntry = (pkg) => {
73
73
  return Object.keys(exported).length > 0;
74
74
  };
75
75
 
76
+ const isTypeKey = (key) => key === 'types' || key === 'typings';
77
+
78
+ const isRuntimeFile = (value) => {
79
+ if (typeof value !== 'string' || !value) return false;
80
+ return !value.endsWith('.d.ts');
81
+ };
82
+
83
+ const hasRuntimeExport = (value) => {
84
+ if (isRuntimeFile(value)) return true;
85
+ if (Array.isArray(value)) {
86
+ for (const item of value) {
87
+ if (hasRuntimeExport(item)) return true;
88
+ }
89
+ return false;
90
+ }
91
+ if (!value || typeof value !== 'object') return false;
92
+ const keys = Object.keys(value);
93
+ for (const key of keys) {
94
+ if (isTypeKey(key)) continue;
95
+ if (hasRuntimeExport(value[key])) return true;
96
+ }
97
+ return false;
98
+ };
99
+
100
+ const pkgHasBin = (pkg) => {
101
+ if (!pkg || typeof pkg !== 'object') return false;
102
+ const bin = pkg.bin;
103
+ if (typeof bin === 'string') return !!bin;
104
+ if (!bin || typeof bin !== 'object' || Array.isArray(bin)) return false;
105
+ return Object.keys(bin).length > 0;
106
+ };
107
+
108
+ const pkgExportsLibrary = (pkg) => {
109
+ if (!pkg || typeof pkg !== 'object') return false;
110
+ if (pkgHasBin(pkg)) return false;
111
+ if (isRuntimeFile(pkg.main)) return true;
112
+ if (isRuntimeFile(pkg.module)) return true;
113
+ return hasRuntimeExport(pkg.exports);
114
+ };
115
+
76
116
  const SKIP_WALK = ['node_modules', '.git', '.review', 'coverage', 'dist'];
77
117
 
78
118
  const collectUsedFromManifest = (used, rel) => {
@@ -156,8 +196,8 @@ const readDepPkg = (root, pkgDir, name) => {
156
196
 
157
197
  const depHasExportEntry = (root, pkgDir, name) => {
158
198
  const pkg = readDepPkg(root, pkgDir, name);
159
- if (!pkg) return true;
160
- return pkgHasExportEntry(pkg);
199
+ if (!pkg) return false;
200
+ return pkgExportsLibrary(pkg);
161
201
  };
162
202
 
163
203
  const collectExportEntries = (root, pkgDir, names) => {
@@ -349,7 +389,7 @@ const markOutdatedChanges = (changes, outdated) =>
349
389
  markChanges(changes, outdated, 'outdated');
350
390
 
351
391
  const depExports = (exportEntries, name) => {
352
- if (!exportEntries || !exportEntries.has(name)) return true;
392
+ if (!exportEntries || !exportEntries.has(name)) return false;
353
393
  return exportEntries.get(name) === true;
354
394
  };
355
395
 
@@ -33,21 +33,50 @@ const commitMessage = (top, sha) => {
33
33
 
34
34
  const lastMessage = (top) => commitMessage(top, 'HEAD');
35
35
 
36
- const COMMIT_FORMAT = ['%H', '%h', '%an', '%ar', '%s'].join('%x1f');
36
+ const COMMIT_FIELDS = ['%H', '%h', '%an', '%ae', '%ar', '%ci', '%D', '%s'];
37
+ const COMMIT_FORMAT = `${COMMIT_FIELDS.join('%x1f')}%x1e%B`;
37
38
  const COMMIT_LIMIT = 256;
38
39
  const UNIT = '\x1f';
40
+ const RECORD = '\x1e';
41
+ const TAG_REF = /^tag: /;
42
+ const REMOTE_HEAD = /\/HEAD(?: -> |$)/;
43
+
44
+ const formatCommitRefs = (raw) => {
45
+ const text = `${raw ?? ''}`.trim();
46
+ if (!text) return '';
47
+ const names = [];
48
+ for (const part of text.split(', ')) {
49
+ const name = part.trim();
50
+ if (!name || TAG_REF.test(name)) continue;
51
+ if (name !== 'HEAD' && REMOTE_HEAD.test(name)) continue;
52
+ names.push(name);
53
+ }
54
+ return names.join(', ');
55
+ };
39
56
 
40
- const parseCommitLine = (line, index) => {
41
- const parts = line.split(UNIT);
42
- if (parts.length < 5) return null;
57
+ const parseCommitRecord = (record, index) => {
58
+ const cut = record.indexOf(RECORD);
59
+ if (cut < 0) return null;
60
+ const parts = record.slice(0, cut).split(UNIT);
61
+ if (parts.length < COMMIT_FIELDS.length) return null;
43
62
  const sha = (parts[0] ?? '').trim();
44
63
  if (!sha) return null;
64
+ const body = record.slice(cut + 1).replace(/\s+$/, '');
65
+ const read = (spec) => {
66
+ const at = COMMIT_FIELDS.indexOf(spec);
67
+ return (parts[at] ?? '').trim();
68
+ };
69
+ const subject = read('%s') || body.split('\n')[0].trim();
45
70
  return {
46
71
  sha,
47
- shortSha: (parts[1] ?? '').trim() || sha.slice(0, 7),
48
- author: (parts[2] ?? '').trim(),
49
- date: (parts[3] ?? '').trim(),
50
- subject: parts.slice(4).join(UNIT).trim(),
72
+ shortSha: read('%h') || sha.slice(0, 7),
73
+ author: read('%an'),
74
+ email: read('%ae'),
75
+ date: read('%ar'),
76
+ when: read('%ci'),
77
+ refs: formatCommitRefs(read('%D')),
78
+ subject,
79
+ body,
51
80
  head: index === 0,
52
81
  };
53
82
  };
@@ -55,14 +84,15 @@ const parseCommitLine = (line, index) => {
55
84
  const listCommits = (top) => {
56
85
  const args = [
57
86
  'log',
87
+ '-z',
58
88
  `--format=${COMMIT_FORMAT}`,
59
89
  `--max-count=${COMMIT_LIMIT}`,
60
90
  ];
61
91
  const result = requireOk(runGit(args, top), 'log failed');
62
92
  const commits = [];
63
- for (const line of result.stdout.split('\n')) {
64
- if (!line) continue;
65
- const entry = parseCommitLine(line, commits.length);
93
+ for (const record of result.stdout.split('\0')) {
94
+ if (!record) continue;
95
+ const entry = parseCommitRecord(record, commits.length);
66
96
  if (entry) commits.push(entry);
67
97
  }
68
98
  return commits;
@@ -154,13 +184,15 @@ const commitSubject = (top, sha) => {
154
184
  };
155
185
 
156
186
  const ancestorWithSubject = (top, sha, want) => {
157
- const args = ['log', `--format=${COMMIT_FORMAT}`, `${sha}^`];
187
+ const args = ['log', '--format=%H%x1f%s', `${sha}^`];
158
188
  const result = runGit(args, top);
159
189
  if (result.status !== 0) return '';
160
190
  for (const line of result.stdout.split('\n')) {
161
- if (!line) continue;
162
- const entry = parseCommitLine(line, 1);
163
- if (entry && entry.subject === want) return entry.sha;
191
+ const cut = line.indexOf(UNIT);
192
+ if (cut < 0) continue;
193
+ const subject = line.slice(cut + 1).trim();
194
+ if (subject !== want) continue;
195
+ return line.slice(0, cut).trim();
164
196
  }
165
197
  return '';
166
198
  };
package/lib/keys.js CHANGED
@@ -6,7 +6,7 @@ const { ACTIONS, ACTION_IDS, BRANCH_ACTIONS, COMMIT_ACTIONS } = actions;
6
6
  const { DIFF_DISABLED, FILES_DISABLED, FILES_GIT_DISABLED } = actions;
7
7
  const { FILES_HIDDEN, FILES_TODO_DISABLED, TODO_DISABLED, BRANCHES_DISABLED } =
8
8
  actions;
9
- const { COMMITS_DISABLED, UNIT_DISABLED } = actions;
9
+ const { COMMITS_DISABLED, UNIT_DISABLED, NPM_DISABLED } = actions;
10
10
  const { disabledActions, diffGitDisabled, branchGitDisabled } = actions;
11
11
  const { commitGitDisabled } = actions;
12
12
  const { actionFromKey, actionHot, actionLetter, buttonWord } = actions;
@@ -65,15 +65,20 @@ const parseCsiSeq = (rest) => {
65
65
  return { params: body.slice(0, i), final: body[i], eat: 2 + i + 1 };
66
66
  };
67
67
 
68
+ const ctrlHeld = (modText) => {
69
+ const mod = parseInt(modText, 10);
70
+ return Number.isFinite(mod) && ((mod - 1) & 4) !== 0;
71
+ };
72
+
68
73
  const keyFromCsi = (paramsText, final) => {
69
74
  const params = paramsText ? paramsText.split(';') : [];
70
75
  if (final === '~') return CSI_TILDE[params[0]] ?? null;
71
76
  const base = CSI_KEYS[final];
72
77
  if (!base) return null;
73
78
  if (params.length < 2) return base;
74
- const mod = parseInt(params[1], 10);
75
- const ctrl = Number.isFinite(mod) && ((mod - 1) & 4) !== 0;
76
- if (ctrl && (base === 'left' || base === 'right')) return `ctrl-${base}`;
79
+ const arrow =
80
+ base === 'left' || base === 'right' || base === 'up' || base === 'down';
81
+ if (ctrlHeld(params[1]) && arrow) return `ctrl-${base}`;
77
82
  return base;
78
83
  };
79
84
 
@@ -193,6 +198,7 @@ module.exports = {
193
198
  TODO_DISABLED,
194
199
  BRANCHES_DISABLED,
195
200
  COMMITS_DISABLED,
201
+ NPM_DISABLED,
196
202
  UNIT_DISABLED,
197
203
  disabledActions,
198
204
  diffGitDisabled,