reslop 0.1.2 → 0.1.4

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,21 @@
2
2
 
3
3
  ## [Unreleased][unreleased]
4
4
 
5
+ ## [0.1.4][] - 2026-09-13
6
+
7
+ - Support a GitLab MR by URL and import review comments
8
+ - Support Windows: npm.cmd, clip, terminal resize, installer
9
+ - Added `-r` read-only mode that blocks stage, unstage, revert, npm
10
+ - Fixed `npm test` so it finds tests on Windows, macOS, and Node 18
11
+ - Added Testing CI on Linux, macOS, and Windows
12
+
13
+ ## [0.1.3][] - 2026-09-12
14
+
15
+ - Showed added/removed and staged/total counts in the file list
16
+ - Highlighted JSX and TSX syntax, including
17
+ - Added vim-style j/k navigation and Ctrl scrolling
18
+ - Improved TUI/UX with background git and npm checks to avoid a freeze
19
+
5
20
  ## [0.1.2][] - 2026-09-11
6
21
 
7
22
  - Review each dependency once across package.json and package-lock
@@ -26,7 +41,9 @@
26
41
  - Feedback and todos that produce a repair plan for an agent
27
42
  - Intra-line highlighting in unified, mixed, and side-by-side layouts
28
43
 
29
- [unreleased]: https://github.com/tshemsedinov/reslop/compare/v0.1.2...HEAD
44
+ [unreleased]: https://github.com/tshemsedinov/reslop/compare/v0.1.4...HEAD
45
+ [0.1.4]: https://github.com/tshemsedinov/reslop/compare/v0.1.3...v0.1.4
46
+ [0.1.3]: https://github.com/tshemsedinov/reslop/compare/v0.1.2...v0.1.3
30
47
  [0.1.2]: https://github.com/tshemsedinov/reslop/compare/v0.1.1...v0.1.2
31
48
  [0.1.1]: https://github.com/tshemsedinov/reslop/compare/v0.1.0...v0.1.1
32
49
  [0.1.0]: https://github.com/tshemsedinov/reslop/releases/tag/v0.1.0
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
21
  - 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.
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: no stage, unstage, revert, or npm apply
41
42
 
42
43
  Reviews go in `.review/YYYY-MM-DD-NN.md` with frontmatter `status`:
43
44
 
@@ -46,25 +47,26 @@ 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
- | `s` | Skip (leave unstaged, drop from review) |
57
- | `←` | Previous remaining block |
58
- | `→` | Next remaining block |
59
- | `l` | File list (git status of this scope) |
60
- | `Esc` | Quit file list or a tool |
61
- | `m` | Cycle unified / mixed / side-by-side |
62
- | `f` | Feedback on this diff block |
63
- | `t` | New todo for this file |
64
- | `⌫` / `Del` | Remove the selected todo |
65
- | `⏎` | Open the selected file |
66
- | `q` | Quit |
67
- | `?` | Help |
50
+ ## Hotkeys
51
+
52
+ | Key | Action |
53
+ | ------------------- | ---------------------------------------- |
54
+ | `a` | Stage this block (`git add`) |
55
+ | `u` | Unstage this block (keep worktree) |
56
+ | `r` | Restore this block to the last commit |
57
+ | `f` | Feedback on this diff block |
58
+ | `t` | New todo for this file |
59
+ | `l` | File list (git status of this scope) |
60
+ | `m` | Cycle unified / mixed / side-by-side |
61
+ | `⏎` | Open the selected file |
62
+ | `Esc` | Quit file list or a tool |
63
+ | `→` / `j` | Next remaining block / next file |
64
+ | `←` / `k` | Previous remaining block / previous file |
65
+ | `Ctrl-e` / `Ctrl-y` | Scroll one line down / up |
66
+ | `Ctrl-f` / `Ctrl-b` | Scroll page down / up |
67
+ | `Ctrl-d` / `Ctrl-u` | Scroll half-page down / up |
68
+ | `⌫` / `Del` | Remove the selected todo |
69
+ | `q` | Quit |
68
70
 
69
71
  ## License
70
72
 
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,13 +116,14 @@ 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
  });
101
123
 
102
- const startSession = (options) => {
124
+ const startSession = (options, extra = {}) => {
103
125
  const session = new Session(options);
104
- session.load();
126
+ if (extra.load) session.load();
105
127
  return session;
106
128
  };
107
129
 
@@ -110,20 +132,23 @@ const openLocalSession = (proc, deps, args, selected, env) => {
110
132
  const repo = deps.repo ?? createGitRepo();
111
133
  const statSync = deps.statSync ?? fs.statSync;
112
134
  const scope = resolveScope(cwd, selected.paths, repo, statSync);
113
- return startSession(
135
+ const session = startSession(
114
136
  sessionOptions(proc, deps, args, env, {
115
137
  repo,
116
138
  paths: scope.paths,
117
139
  rev: scope.rev,
118
140
  }),
119
141
  );
142
+ session.ensureRepo();
143
+ return session;
120
144
  };
121
145
 
122
- const openPullRequestSession = async (proc, deps, args, selected, env) => {
146
+ const openRemoteSession = async (proc, deps, args, selected, env) => {
147
+ const remote = REMOTE_CHANGE[selected.kind];
123
148
  const cwd = deps.cwd ?? proc.cwd();
124
- const loader = deps.loadPullRequest ?? loadPullRequest;
125
- const token = githubToken(env);
126
- 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), {
127
152
  cwd,
128
153
  paths: selected.paths,
129
154
  token,
@@ -140,18 +165,26 @@ const openPullRequestSession = async (proc, deps, args, selected, env) => {
140
165
  sourceLabel: loaded.sourceLabel,
141
166
  change,
142
167
  }),
168
+ { load: true },
143
169
  );
144
170
  };
145
171
 
146
172
  const loadSession = async (proc, deps, args) => {
147
173
  const env = proc.env ?? {};
148
174
  const selected = selectChangeSource(args.paths);
149
- if (selected.kind === 'github-pr') {
150
- return openPullRequestSession(proc, deps, args, selected, env);
175
+ if (REMOTE_CHANGE[selected.kind]) {
176
+ return openRemoteSession(proc, deps, args, selected, env);
151
177
  }
152
178
  return openLocalSession(proc, deps, args, selected, env);
153
179
  };
154
180
 
181
+ const runUi = async (session, proc) => {
182
+ const code = await session.startUi();
183
+ if (session.loadError) return fail(proc.stderr, session.loadError);
184
+ if (session.emptyReview) proc.stdout.write('nothing to review\n');
185
+ return code;
186
+ };
187
+
155
188
  const run = async (proc, deps = {}) => {
156
189
  const stdout = proc.stdout;
157
190
  const stderr = proc.stderr;
@@ -167,15 +200,19 @@ const run = async (proc, deps = {}) => {
167
200
  } catch (error) {
168
201
  return fail(stderr, error);
169
202
  }
203
+ const tty = proc.stdin.isTTY === true && stdout.isTTY === true;
204
+ if (tty) return runUi(session, proc);
205
+ try {
206
+ await session.loadReady();
207
+ } catch (error) {
208
+ return fail(stderr, error);
209
+ }
170
210
  if (!session.items.length) {
171
211
  stdout.write('nothing to review\n');
172
212
  return 0;
173
213
  }
174
- if (!proc.stdin.isTTY || !stdout.isTTY) {
175
- stderr.write('reslop: interactive terminal required\n');
176
- return 1;
177
- }
178
- return session.startUi();
214
+ stderr.write('reslop: interactive terminal required\n');
215
+ return 1;
179
216
  };
180
217
 
181
218
  module.exports = {
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/detect.js CHANGED
@@ -11,6 +11,8 @@ const LANG = {
11
11
  cjs: 'js',
12
12
  ts: 'ts',
13
13
  typescript: 'ts',
14
+ jsx: 'jsx',
15
+ tsx: 'tsx',
14
16
  json: 'json',
15
17
  csv: 'csv',
16
18
  html: 'html',
package/lib/files.js CHANGED
@@ -33,26 +33,50 @@ const fileStatus = (origins) => {
33
33
  return key;
34
34
  };
35
35
 
36
+ const lineDelta = ({ hunk, blockId }) => {
37
+ const delta = { added: 0, removed: 0 };
38
+ if (!hunk) return delta;
39
+ const { lines } = hunk;
40
+ if (!lines) return delta;
41
+ const scoped = typeof blockId === 'number';
42
+ for (const { type, blockId: lineBlock } of lines) {
43
+ if (scoped && lineBlock !== blockId) continue;
44
+ if (type === 'add') delta.added += 1;
45
+ else if (type === 'del') delta.removed += 1;
46
+ }
47
+ return delta;
48
+ };
49
+
36
50
  const fileEntries = (items) => {
37
51
  const groups = new Map();
38
52
  for (let i = 0; i < items.length; i++) {
39
53
  const item = items[i];
40
54
  const rel = itemPath(item);
41
- let group = groups.get(rel);
42
- if (!group) {
43
- group = { path: rel, origins: [], firstIndex: i, remaining: 0 };
44
- groups.set(rel, group);
55
+ if (!groups.has(rel)) {
56
+ groups.set(rel, {
57
+ path: rel,
58
+ origins: [],
59
+ firstIndex: i,
60
+ remaining: 0,
61
+ added: 0,
62
+ removed: 0,
63
+ staged: 0,
64
+ unstaged: 0,
65
+ });
45
66
  }
46
- group.origins.push(item.origin);
67
+ const group = groups.get(rel);
68
+ const { origin } = item;
69
+ group.origins.push(origin);
47
70
  group.remaining += 1;
71
+ const delta = lineDelta(item);
72
+ group.added += delta.added;
73
+ group.removed += delta.removed;
74
+ if (origin === 'staged') group.staged += 1;
75
+ if (origin === 'unstaged') group.unstaged += 1;
48
76
  }
49
- const entries = [];
50
- for (const group of groups.values()) {
51
- const path = group.path;
52
- const status = fileStatus(group.origins);
53
- const firstIndex = group.firstIndex;
54
- const remaining = group.remaining;
55
- entries.push({ path, status, firstIndex, remaining });
77
+ const entries = [...groups.values()];
78
+ for (const group of entries) {
79
+ group.status = fileStatus(group.origins);
56
80
  }
57
81
  return entries;
58
82
  };