reslop 0.1.3 → 0.1.5

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,22 @@
2
2
 
3
3
  ## [Unreleased][unreleased]
4
4
 
5
+ ## [0.1.5][] - 2026-09-14
6
+
7
+ - Added `r` to reload diffs from disk
8
+ - Added `e` to edit added lines in place
9
+ - Added `d` on quit to discard a new review file
10
+ - Added `c` to commit, amend, or fixup staged changes
11
+ - Reassign hotkeys to free needed keys for new operations
12
+
13
+ ## [0.1.4][] - 2026-09-13
14
+
15
+ - Support a GitLab MR by URL and import review comments
16
+ - Support Windows: npm.cmd, clip, terminal resize, installer
17
+ - Added `-r` read-only mode that blocks stage, unstage, revert, npm
18
+ - Fixed `npm test` so it finds tests on Windows, macOS, and Node 18
19
+ - Added Testing CI on Linux, macOS, and Windows
20
+
5
21
  ## [0.1.3][] - 2026-09-12
6
22
 
7
23
  - Showed added/removed and staged/total counts in the file list
@@ -33,7 +49,9 @@
33
49
  - Feedback and todos that produce a repair plan for an agent
34
50
  - Intra-line highlighting in unified, mixed, and side-by-side layouts
35
51
 
36
- [unreleased]: https://github.com/tshemsedinov/reslop/compare/v0.1.3...HEAD
52
+ [unreleased]: https://github.com/tshemsedinov/reslop/compare/v0.1.5...HEAD
53
+ [0.1.5]: https://github.com/tshemsedinov/reslop/compare/v0.1.4...v0.1.5
54
+ [0.1.4]: https://github.com/tshemsedinov/reslop/compare/v0.1.3...v0.1.4
37
55
  [0.1.3]: https://github.com/tshemsedinov/reslop/compare/v0.1.2...v0.1.3
38
56
  [0.1.2]: https://github.com/tshemsedinov/reslop/compare/v0.1.1...v0.1.2
39
57
  [0.1.1]: https://github.com/tshemsedinov/reslop/compare/v0.1.0...v0.1.1
package/README.md CHANGED
@@ -1,4 +1,11 @@
1
- # reslop — Review diff and plan changes
1
+ # 👁️ reslop — Review diff and plan changes
2
+
3
+ [![ci status](https://github.com/tshemsedinov/reslop/workflows/Testing%20CI/badge.svg)](https://github.com/tshemsedinov/reslop/actions?query=workflow%3A%22Testing+CI%22+branch%3Amain)
4
+ [![snyk](https://snyk.io/test/github/tshemsedinov/reslop/badge.svg)](https://snyk.io/test/github/tshemsedinov/reslop)
5
+ [![npm version](https://badge.fury.io/js/reslop.svg)](https://badge.fury.io/js/reslop)
6
+ [![npm downloads/month](https://img.shields.io/npm/dm/reslop.svg)](https://www.npmjs.com/package/reslop)
7
+ [![npm downloads](https://img.shields.io/npm/dt/reslop.svg)](https://www.npmjs.com/package/reslop)
8
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/tshemsedinov/reslop/blob/main/LICENSE)
2
9
 
3
10
  > Turn generated changes into owned changes
4
11
 
@@ -9,35 +16,29 @@ for the agent.
9
16
  Review → Plan → Repair → Verify
10
17
  ```
11
18
 
12
- - Review uncommitted git diffs, a given commit, or a GitHub pull request.
19
+ - Review uncommitted git diffs, a given commit, a GitHub PR, or a GitLab MR.
13
20
  - Stage, unstage, or revert each contiguous block of diff lines.
14
- - Leave feedback and todos that become a repair plan for the agent.
15
- - Import GitHub pull request review comments into the local plan for AI.
21
+ - Leave feedback, todos, and in-place code proposals that become a repair plan.
22
+ - Import GitHub PR and GitLab MR review comments into the local plan for AI.
16
23
  - Review each npm dependency once across `package.json` and the lockfile.
17
24
  - Propose unused removals, npm audit fixes, and outdated updates as diffs.
18
25
 
19
- Commit and pull request review is read-only. Applying a dependency
20
- proposal stages the files and runs `npm i` or `npm uninstall`.
21
-
22
- Intra-line highlighting paints the words that actually changed in a
23
- stronger red/green. A close edit inside one word still marks only
24
- those characters.
25
-
26
26
  ## Install
27
27
 
28
28
  ```bash
29
29
  npm i -g reslop
30
30
  ```
31
31
 
32
+ Works on Linux, macOS, and Windows. On Windows use Windows Terminal.
33
+
32
34
  ## Usage
33
35
 
34
- ```bash
35
- reslop
36
- reslop -n
37
- reslop path/file
38
- reslop 7ac260c
39
- reslop https://github.com/metarhia/metacom/pull/555
40
- ```
36
+ - `reslop` uncommitted diffs in this repository
37
+ - `reslop path/file` show only that path or file
38
+ - `reslop 7ac260c` show that commit (read-only)
39
+ - `reslop https://github.com/metarhia/metacom/pull/555` GitHub/GitLab PR/MR
40
+ - `reslop -n` start a new review even if the latest is still editing
41
+ - `reslop -r` read-only mode
41
42
 
42
43
  Reviews go in `.review/YYYY-MM-DD-NN.md` with frontmatter `status`:
43
44
 
@@ -46,26 +47,27 @@ Reviews go in `.review/YYYY-MM-DD-NN.md` with frontmatter `status`:
46
47
  - `partial`: AI started; some items remain
47
48
  - `done`: all items marked `[x]`
48
49
 
49
- ## Keys and buttons
50
-
51
- | Key | Action |
52
- | ------------------- | ---------------------------------------- |
53
- | `a` | Stage this block (`git add`) |
54
- | `u` | Unstage this block (keep worktree) |
55
- | `r` | Restore this block to the last commit |
56
- | `f` | Feedback on this diff block |
57
- | `t` | New todo for this file |
58
- | `l` | File list (git status of this scope) |
59
- | `m` | Cycle unified / mixed / side-by-side |
60
- | `⏎` | Open the selected file |
61
- | `Esc` | Quit file list or a tool |
62
- | `→` / `j` | Next remaining block / next file |
63
- | `←` / `k` | Previous remaining block / previous file |
64
- | `Ctrl-e` / `Ctrl-y` | Scroll one line down / up |
65
- | `Ctrl-f` / `Ctrl-b` | Scroll page down / up |
66
- | `Ctrl-d` / `Ctrl-u` | Scroll half-page down / up |
67
- | `⌫` / `Del` | Remove the selected todo |
68
- | `q` | Quit |
50
+ ## Hotkeys
51
+
52
+ | Key | Action |
53
+ | ------------------- | ----------------------------------------------- |
54
+ | `a` | Stage this diff (git add) |
55
+ | `u` | Unstage this diff |
56
+ | `d` | Drop this diff to the last commit |
57
+ | `c` | Commit: then `c` commit / `a` amend / `f` fixup |
58
+ | `f` | Feedback on this diff |
59
+ | `t` | New todo |
60
+ | `e` | Edit added lines |
61
+ | `r` | Reload diffs from disk |
62
+ | `m` | Mode: unified / mixed / side-by-side |
63
+ | `⏎` | Open the selected file |
64
+ | `→` / `j` | Next remaining diff / next file |
65
+ | `←` / `k` | Previous remaining diff / previous file |
66
+ | `Ctrl-e` / `Ctrl-y` | Scroll one line down / up |
67
+ | `Ctrl-f` / `Ctrl-b` | Scroll page down / up |
68
+ | `Ctrl-d` / `Ctrl-u` | Scroll half-page down / up |
69
+ | `⌫` / `Del` | Remove the selected todo |
70
+ | `Esc` / `q` | Quit |
69
71
 
70
72
  ## License
71
73
 
package/lib/api.js CHANGED
@@ -8,6 +8,7 @@ const render = require('./render.js');
8
8
  const highlight = require('./highlight.js');
9
9
  const detect = require('./detect.js');
10
10
  const github = require('./github.js');
11
+ const gitlab = require('./gitlab.js');
11
12
  const source = require('./source.js');
12
13
  const { Session } = require('./session.js');
13
14
  const cli = require('./cli.js');
@@ -25,6 +26,8 @@ module.exports = {
25
26
  createGitRepo: git.createGitRepo,
26
27
  parseGithubPrUrl: github.parseGithubPrUrl,
27
28
  loadPullRequest: github.loadPullRequest,
29
+ parseGitlabMrUrl: gitlab.parseGitlabMrUrl,
30
+ loadMergeRequest: gitlab.loadMergeRequest,
28
31
  selectChangeSource: source.selectChangeSource,
29
32
  decodeChunk: keys.decodeChunk,
30
33
  hitAction: keys.hitAction,
package/lib/cli.js CHANGED
@@ -5,6 +5,7 @@ const path = require('node:path');
5
5
 
6
6
  const { createGitRepo } = require('./git.js');
7
7
  const { githubToken, loadPullRequest } = require('./github.js');
8
+ const { gitlabToken, loadMergeRequest } = require('./gitlab.js');
8
9
  const { selectChangeSource, createLoadedSource } = require('./source.js');
9
10
  const { Session } = require('./session.js');
10
11
 
@@ -12,7 +13,22 @@ const HEX_REV = /^[0-9a-f]{4,40}$/i;
12
13
  const HEAD_REV = /^HEAD([~^].*)?$/i;
13
14
  const RANGE_REV = /[~^]/;
14
15
 
15
- const USAGE = 'Usage: reslop [-n] [path | commit | pr-url]';
16
+ const USAGE = 'Usage: reslop [-n] [-r] [path | commit | pr-url | mr-url]';
17
+
18
+ const REMOTE_CHANGE = {
19
+ 'github-pr': {
20
+ load: 'loadPullRequest',
21
+ fallback: loadPullRequest,
22
+ token: githubToken,
23
+ target: (selected) => selected.pr,
24
+ },
25
+ 'gitlab-mr': {
26
+ load: 'loadMergeRequest',
27
+ fallback: loadMergeRequest,
28
+ token: gitlabToken,
29
+ target: (selected) => selected.mr,
30
+ },
31
+ };
16
32
 
17
33
  const errorMessage = (reason) => {
18
34
  if (reason instanceof Error) return reason.message;
@@ -37,17 +53,22 @@ const failUsage = (stderr, reason) => {
37
53
  const parseArgv = (argv) => {
38
54
  const paths = [];
39
55
  let newReview = false;
56
+ let readOnly = false;
40
57
  for (const arg of argv) {
41
58
  if (arg === '-n') {
42
59
  newReview = true;
43
60
  continue;
44
61
  }
62
+ if (arg === '-r') {
63
+ readOnly = true;
64
+ continue;
65
+ }
45
66
  if (arg.startsWith('-')) {
46
67
  throw new Error(`unknown option ${arg}`);
47
68
  }
48
69
  paths.push(arg);
49
70
  }
50
- return { paths, newReview };
71
+ return { paths, newReview, readOnly };
51
72
  };
52
73
 
53
74
  const looksLikeRev = (spec) => {
@@ -95,6 +116,7 @@ const sessionOptions = (proc, deps, args, env, extra) => ({
95
116
  getSize: deps.getSize,
96
117
  startPane: 'files',
97
118
  newReview: args.newReview,
119
+ readOnly: args.readOnly,
98
120
  audit: true,
99
121
  ...extra,
100
122
  });
@@ -121,11 +143,12 @@ const openLocalSession = (proc, deps, args, selected, env) => {
121
143
  return session;
122
144
  };
123
145
 
124
- const openPullRequestSession = async (proc, deps, args, selected, env) => {
146
+ const openRemoteSession = async (proc, deps, args, selected, env) => {
147
+ const remote = REMOTE_CHANGE[selected.kind];
125
148
  const cwd = deps.cwd ?? proc.cwd();
126
- const loader = deps.loadPullRequest ?? loadPullRequest;
127
- const token = githubToken(env);
128
- const loaded = await loader(selected.pr, {
149
+ const loader = deps[remote.load] ?? remote.fallback;
150
+ const token = remote.token(env);
151
+ const loaded = await loader(remote.target(selected), {
129
152
  cwd,
130
153
  paths: selected.paths,
131
154
  token,
@@ -149,8 +172,8 @@ const openPullRequestSession = async (proc, deps, args, selected, env) => {
149
172
  const loadSession = async (proc, deps, args) => {
150
173
  const env = proc.env ?? {};
151
174
  const selected = selectChangeSource(args.paths);
152
- if (selected.kind === 'github-pr') {
153
- return openPullRequestSession(proc, deps, args, selected, env);
175
+ if (REMOTE_CHANGE[selected.kind]) {
176
+ return openRemoteSession(proc, deps, args, selected, env);
154
177
  }
155
178
  return openLocalSession(proc, deps, args, selected, env);
156
179
  };
package/lib/clipboard.js CHANGED
@@ -3,13 +3,9 @@
3
3
  const { spawnSync } = require('node:child_process');
4
4
 
5
5
  const { ESC } = require('./ansi.js');
6
+ const { clipTools, spawnBase } = require('./sys.js');
6
7
 
7
8
  const CLIP_TIMEOUT_MS = 1000;
8
- const CLIP_TOOLS = [
9
- { cmd: 'wl-copy', args: [] },
10
- { cmd: 'xclip', args: ['-selection', 'clipboard'] },
11
- { cmd: 'xsel', args: ['--clipboard', '--input'] },
12
- ];
13
9
 
14
10
  const osc52 = (text) => {
15
11
  const b64 = Buffer.from(text, 'utf8').toString('base64');
@@ -17,11 +13,15 @@ const osc52 = (text) => {
17
13
  };
18
14
 
19
15
  const runClip = (cmd, args, text) => {
20
- const result = spawnSync(cmd, args, {
21
- input: text,
22
- encoding: 'utf8',
23
- timeout: CLIP_TIMEOUT_MS,
24
- });
16
+ const result = spawnSync(
17
+ cmd,
18
+ args,
19
+ spawnBase({
20
+ input: text,
21
+ encoding: 'utf8',
22
+ timeout: CLIP_TIMEOUT_MS,
23
+ }),
24
+ );
25
25
  if (result.error) return false;
26
26
  return result.status === 0;
27
27
  };
@@ -33,7 +33,7 @@ const copyText = (text, stdout) => {
33
33
  stdout.write(osc52(text));
34
34
  ok = true;
35
35
  }
36
- for (const tool of CLIP_TOOLS) {
36
+ for (const tool of clipTools()) {
37
37
  if (runClip(tool.cmd, tool.args, text)) return true;
38
38
  }
39
39
  return ok;
package/lib/diff.js CHANGED
@@ -773,10 +773,78 @@ const windowAroundBlock = (lines, blockId, radius) => {
773
773
  return lines.slice(from, to);
774
774
  };
775
775
 
776
- const displayLines = (hunk, blockId, layout = 'unified', radius) => {
776
+ const insertAddIndex = (lines) => {
777
+ for (let i = lines.length - 1; i >= 0; i--) {
778
+ if (isCtxType(lines[i].type)) continue;
779
+ return i + 1;
780
+ }
781
+ return lines.length;
782
+ };
783
+
784
+ const taggedAdd = (text, blockId, start, end) => ({
785
+ type: 'add',
786
+ text,
787
+ noNl: false,
788
+ blockId,
789
+ wrap: true,
790
+ editStart: start,
791
+ editEnd: end,
792
+ editLast: true,
793
+ });
794
+
795
+ const replaceAdds = (lines, overlay, blockId) => {
796
+ const text = overlay.text ?? '';
797
+ const keepEmpty = overlay.keepEmpty === true;
798
+ if (text === '' && !keepEmpty) {
799
+ return lines.filter((line) => line.type !== 'add');
800
+ }
801
+ const texts = text.split('\n');
802
+ const tagged = [];
803
+ let offset = 0;
804
+ for (let i = 0; i < texts.length; i++) {
805
+ const row = texts[i];
806
+ const start = offset;
807
+ const end = start + row.length;
808
+ const isLast = i === texts.length - 1;
809
+ tagged.push(taggedAdd(row, blockId, start, end));
810
+ offset = isLast ? end : end + 1;
811
+ }
812
+ const out = [];
813
+ let placed = 0;
814
+ let lastAdd = -1;
815
+ for (const line of lines) {
816
+ if (line.type !== 'add') {
817
+ out.push(line);
818
+ continue;
819
+ }
820
+ if (placed >= tagged.length) continue;
821
+ out.push(tagged[placed]);
822
+ lastAdd = out.length - 1;
823
+ placed += 1;
824
+ }
825
+ const extras = tagged.slice(placed);
826
+ if (!extras.length) return out;
827
+ const at = lastAdd >= 0 ? lastAdd + 1 : insertAddIndex(out);
828
+ out.splice(at, 0, ...extras);
829
+ return out;
830
+ };
831
+
832
+ const blockAddText = (hunk, blockId) => {
833
+ if (!hunk) return '';
834
+ const parts = [];
835
+ for (const line of hunk.lines) {
836
+ if (line.type !== 'add') continue;
837
+ if (line.blockId !== blockId) continue;
838
+ parts.push(line.text);
839
+ }
840
+ return parts.join('\n');
841
+ };
842
+
843
+ const displayLines = (hunk, blockId, layout = 'unified', radius, overlay) => {
777
844
  const flat = flattenBlock(hunk, blockId, 'new');
845
+ const source = overlay ? replaceAdds(flat, overlay, blockId) : flat;
778
846
  const around = radius ?? DISPLAY_CONTEXT;
779
- const windowed = windowAroundBlock(flat, blockId, around);
847
+ const windowed = windowAroundBlock(source, blockId, around);
780
848
  const lines = windowed.map((line) => ({ ...line }));
781
849
  const painted = attachInline(lines);
782
850
  const align = LAYOUT_ALIGN[layout];
@@ -877,6 +945,7 @@ module.exports = {
877
945
  countSides,
878
946
  formatPatch,
879
947
  displayLines,
948
+ blockAddText,
880
949
  synthesizeNewFile,
881
950
  itemsFromFiles,
882
951
  refreshIndexPatches,
package/lib/git.js CHANGED
@@ -7,12 +7,18 @@ const path = require('node:path');
7
7
  const diff = require('./diff.js');
8
8
  const { parseDiff, synthesizeNewFile, itemsFromFiles } = diff;
9
9
  const { itemPath, isPathInScope, REVIEW_DIR } = require('./files.js');
10
+ const { npmBin, spawnBase } = require('./sys.js');
10
11
  const deps = require('./deps.js');
11
12
  const { foldDepItems, mergeDepFile, depFileMeta } = deps;
12
13
  const { collectUsedNames, parseAuditReport, parseOutdatedReport } = deps;
13
14
  const { mergeProposedItems, MANIFEST, LOCKFILE } = deps;
14
15
 
15
- const GIT_CONFIG = ['-c', 'core.quotepath=false'];
16
+ const GIT_CONFIG = [
17
+ '-c',
18
+ 'core.quotepath=false',
19
+ '-c',
20
+ 'i18n.logOutputEncoding=utf-8',
21
+ ];
16
22
  const DIFF_OPTS = ['--no-color', '--no-ext-diff', '--no-renames', '-U3'];
17
23
  const GIT_ENCODING = 'utf8';
18
24
  const GIT_MAX_BUFFER = 32 * 1024 * 1024;
@@ -71,7 +77,7 @@ const runProc = (cmd, args, options = {}) =>
71
77
  let stderr = '';
72
78
  let settled = false;
73
79
  let timer = null;
74
- const child = spawn(cmd, args, { cwd, env });
80
+ const child = spawn(cmd, args, spawnBase({ cwd, env }));
75
81
  const finish = (result) => {
76
82
  if (settled) return;
77
83
  settled = true;
@@ -135,7 +141,7 @@ const runGit = (args, cwd, input) => {
135
141
  const env = { ...process.env, GIT_OPTIONAL_LOCKS: '0' };
136
142
  const encoding = GIT_ENCODING;
137
143
  const maxBuffer = GIT_MAX_BUFFER;
138
- const options = { cwd, encoding, input, maxBuffer, env };
144
+ const options = spawnBase({ cwd, encoding, input, maxBuffer, env });
139
145
  const result = spawnSync('git', args, options);
140
146
  return gitResult(result);
141
147
  };
@@ -275,18 +281,22 @@ const readDepSides = (top, rev, origin, rel) => {
275
281
  };
276
282
 
277
283
  const runNpmJson = (top, args, timeout) => {
278
- const result = spawnSync('npm', args, {
279
- cwd: top,
280
- encoding: GIT_ENCODING,
281
- maxBuffer: GIT_MAX_BUFFER,
282
- timeout,
283
- });
284
+ const result = spawnSync(
285
+ npmBin(),
286
+ args,
287
+ spawnBase({
288
+ cwd: top,
289
+ encoding: GIT_ENCODING,
290
+ maxBuffer: GIT_MAX_BUFFER,
291
+ timeout,
292
+ }),
293
+ );
284
294
  if (result.error) return '';
285
295
  return result.stdout || '';
286
296
  };
287
297
 
288
298
  const runNpmJsonAsync = async (top, args, timeout, signal) => {
289
- const result = await runProc('npm', args, {
299
+ const result = await runProc(npmBin(), args, {
290
300
  cwd: top,
291
301
  encoding: GIT_ENCODING,
292
302
  maxBuffer: GIT_MAX_BUFFER,
@@ -443,12 +453,16 @@ const applyDepWorktree = (top, rel, change, origin) => {
443
453
  };
444
454
 
445
455
  const runNpm = (cwd, args) =>
446
- spawnSync('npm', args, {
447
- cwd,
448
- encoding: GIT_ENCODING,
449
- maxBuffer: GIT_MAX_BUFFER,
450
- timeout: INSTALL_MS,
451
- });
456
+ spawnSync(
457
+ npmBin(),
458
+ args,
459
+ spawnBase({
460
+ cwd,
461
+ encoding: GIT_ENCODING,
462
+ maxBuffer: GIT_MAX_BUFFER,
463
+ timeout: INSTALL_MS,
464
+ }),
465
+ );
452
466
 
453
467
  const firstDepRel = (files, kind) => {
454
468
  for (const rel of files) {
@@ -831,6 +845,28 @@ const revertItem = (top, item) => {
831
845
  }
832
846
  };
833
847
 
848
+ const lastMessage = (top) => {
849
+ const result = runGit(['log', '-1', '--format=%B'], top);
850
+ if (result.status !== 0) return '';
851
+ return (result.stdout ?? '').replace(/\s+$/, '');
852
+ };
853
+
854
+ const commitChanges = (top, kind, message) => {
855
+ if (kind === 'fixup') {
856
+ const target = (message ?? '').trim() || 'HEAD';
857
+ const result = runGit(['commit', `--fixup=${target}`], top);
858
+ requireOk(result);
859
+ return;
860
+ }
861
+ const args = ['commit'];
862
+ if (kind === 'amend') args.push('--amend');
863
+ args.push('-F', '-');
864
+ const text = message ?? '';
865
+ const body = text.endsWith('\n') ? text : `${text}\n`;
866
+ const result = runGit(args, top, body);
867
+ requireOk(result);
868
+ };
869
+
834
870
  const revertFile = (top, rel, items) => {
835
871
  const paths = new Set([rel]);
836
872
  for (const item of items) {
@@ -853,6 +889,8 @@ const createGitRepo = () => ({
853
889
  unstage: unstageItem,
854
890
  revert: revertItem,
855
891
  revertFile,
892
+ commit: commitChanges,
893
+ lastMessage,
856
894
  });
857
895
 
858
896
  module.exports = {
@@ -862,5 +900,7 @@ module.exports = {
862
900
  addItem,
863
901
  unstageItem,
864
902
  revertItem,
903
+ commitChanges,
904
+ lastMessage,
865
905
  createGitRepo,
866
906
  };