reslop 0.1.0 → 0.1.1

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,15 +2,24 @@
2
2
 
3
3
  ## [Unreleased][unreleased]
4
4
 
5
- ## [0.1.0][] - 2026-09-09
5
+ ## [0.1.1][] - 2026-09-10
6
+
7
+ - Open a GitHub pull request by URL and review its diff
8
+ - Import GitHub pull request review comments into the local review
9
+ - Improve TUI navigation and hotkeys
10
+ - CLI simplification and optimisation
11
+ - Dix diff highlighting for snake_case identifier changes
12
+ - Updated package metadata and README
13
+
14
+ ## [0.1.0][] - 2026-09-07
6
15
 
7
16
  ### Added
8
17
 
9
- - Interactive terminal git diff with intra-line highlighting
10
- - Stage, unstage, revert, and skip contiguous diff blocks
11
- - Feedback and todos in `.review/` markdown for an agent to execute
12
- - Unified, mixed, and side-by-side layouts
13
- - Review of a worktree, a path, or a given commit (read-only)
18
+ - Interactive terminal review of uncommitted git diffs with stage, unstage,
19
+ revert, and skip
20
+ - Feedback and todos that produce a repair plan for an agent
21
+ - Intra-line highlighting in unified, mixed, and side-by-side layouts
14
22
 
15
- [unreleased]: https://github.com/tshemsedinov/metadiff/compare/v0.1.0...HEAD
16
- [0.1.0]: https://github.com/tshemsedinov/metadiff/releases/tag/v0.1.0
23
+ [unreleased]: https://github.com/tshemsedinov/reslop/compare/v0.1.1...HEAD
24
+ [0.1.1]: https://github.com/tshemsedinov/reslop/compare/v0.1.0...v0.1.1
25
+ [0.1.0]: https://github.com/tshemsedinov/reslop/releases/tag/v0.1.0
package/README.md CHANGED
@@ -1,12 +1,21 @@
1
- # reslop
1
+ # reslop — Review diff and plan changes
2
2
 
3
- > Review diff and plan changes
3
+ > Turn generated changes into owned changes
4
4
 
5
- - Prepare specs for the agent.
5
+ ```text
6
+ Review → Plan → Repair → Verify
7
+ ```
8
+
9
+ `0.1.x` implements Review: open a change, leave findings and todos, and
10
+ prepare a repair plan for the agent. Plan, repair, and verify come later.
11
+
12
+ - Prepare review findings and a repair plan for the agent.
6
13
  - Leave feedback, add todos.
7
14
  - Generate a plan the agent executes.
8
15
 
9
- Walk uncommitted git changes, or a given commit. Each contiguous block of diff lines can be staged, unstaged, reverted; add review and todos. Commit review is read-only.
16
+ Walk uncommitted git changes, a given commit, or a GitHub pull request.
17
+ Each contiguous block of diff lines can be staged, unstaged, reverted;
18
+ add review and todos. Commit and pull request review is read-only.
10
19
 
11
20
  Intra-line highlighting paints the words that actually changed in a
12
21
  stronger red/green. A close edit inside one word still marks only
@@ -14,52 +23,26 @@ those characters.
14
23
 
15
24
  ## Install
16
25
 
17
- Global:
18
-
19
26
  ```bash
20
27
  npm i -g reslop
21
28
  ```
22
29
 
23
- Local:
24
-
25
- ```bash
26
- npx reslop
27
- ```
28
-
29
30
  ## Usage
30
31
 
31
32
  ```bash
32
33
  reslop
33
34
  reslop -n
34
- reslop path/to/folder
35
- reslop path/to/file
35
+ reslop path/file
36
36
  reslop 7ac260c
37
- reslop HEAD~1 path/to/file
38
- reslop --help
37
+ reslop https://github.com/metarhia/metacom/pull/555
39
38
  ```
40
39
 
41
- Starts on the diff. No arguments, or a folder, reviews that scope.
42
- A file argument opens that file's diff. A git revision that is not
43
- also an existing path opens that commit's patch. Esc opens the file
44
- list; Esc again quits.
45
-
46
- Specs go in `.review/YYYY-MM-DD-NN.md` with frontmatter `status`:
47
-
48
- | Status | Meaning |
49
- | ------- | ------------------------------------------- |
50
- | editing | Still writing the review in reslop |
51
- | ready | Ready for AI to work through the checkboxes |
52
- | partial | AI started; some items remain |
53
- | done | All items marked `[x]` |
54
-
55
- `reslop` resumes the latest file when its status is `editing`. Any other
56
- status starts a new file. `-n` / `--new` always starts a new file. Quit asks
57
- `f` (finish as `ready`, for the agent to execute) or `c` (keep `editing` and
58
- continue next time). The agent should execute `ready` and `partial` reviews.
59
- Files in `.review/` are omitted from the diff list.
40
+ Reviews go in `.review/YYYY-MM-DD-NN.md` with frontmatter `status`:
60
41
 
61
- A `##` heading names the file. Todos are checkboxes under it. Feedback is a
62
- checkbox with ` - path:old:new:block` on the same line.
42
+ - `editing`: still writing the review in reslop
43
+ - `ready`: ready for AI to work through the checkboxes
44
+ - `partial`: AI started; some items remain
45
+ - `done`: all items marked `[x]`
63
46
 
64
47
  ## Keys and buttons
65
48
 
@@ -72,7 +55,7 @@ checkbox with ` - path:old:new:block` on the same line.
72
55
  | `←` | Previous remaining block |
73
56
  | `→` | Next remaining block |
74
57
  | `l` | File list (git status of this scope) |
75
- | `Esc` | File list, then quit |
58
+ | `Esc` | Quit file list or a tool |
76
59
  | `m` | Cycle unified / mixed / side-by-side |
77
60
  | `f` | Feedback on this diff block |
78
61
  | `t` | New todo for this file |
package/lib/api.js CHANGED
@@ -7,6 +7,8 @@ const keys = require('./keys.js');
7
7
  const render = require('./render.js');
8
8
  const highlight = require('./highlight.js');
9
9
  const detect = require('./detect.js');
10
+ const github = require('./github.js');
11
+ const source = require('./source.js');
10
12
  const { Session } = require('./session.js');
11
13
  const cli = require('./cli.js');
12
14
 
@@ -21,6 +23,9 @@ module.exports = {
21
23
  formatPatch: diff.formatPatch,
22
24
  itemsFromFiles: diff.itemsFromFiles,
23
25
  createGitRepo: git.createGitRepo,
26
+ parseGithubPrUrl: github.parseGithubPrUrl,
27
+ loadPullRequest: github.loadPullRequest,
28
+ selectChangeSource: source.selectChangeSource,
24
29
  decodeChunk: keys.decodeChunk,
25
30
  hitAction: keys.hitAction,
26
31
  renderFrame: render.renderFrame,
package/lib/cli.js CHANGED
@@ -4,22 +4,16 @@ const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
 
6
6
  const { createGitRepo } = require('./git.js');
7
+ const { githubToken, loadPullRequest } = require('./github.js');
8
+ const { selectChangeSource, createLoadedSource } = require('./source.js');
7
9
  const { Session } = require('./session.js');
8
- const { ACTIONS, actionHot } = require('./keys.js');
9
-
10
- const FLAGS = Object.assign(Object.create(null), {
11
- '-h': 'help',
12
- '--help': 'help',
13
- '-v': 'version',
14
- '--version': 'version',
15
- '-n': 'newReview',
16
- '--new': 'newReview',
17
- });
18
10
 
19
11
  const HEX_REV = /^[0-9a-f]{4,40}$/i;
20
12
  const HEAD_REV = /^HEAD([~^].*)?$/i;
21
13
  const RANGE_REV = /[~^]/;
22
14
 
15
+ const USAGE = 'Usage: reslop [-n] [path | commit | pr-url]';
16
+
23
17
  const errorMessage = (reason) => {
24
18
  if (reason instanceof Error) return reason.message;
25
19
  if (reason === null || reason === undefined) return '';
@@ -32,32 +26,18 @@ const fail = (stderr, reason) => {
32
26
  return 1;
33
27
  };
34
28
 
35
- const helpText = () => {
36
- const lines = [
37
- 'Usage: reslop [-n] [commit] [path...]',
38
- '',
39
- 'Review uncommitted git changes, or a given commit (read only).',
40
- 'Resumes the latest .review file when its status is editing.',
41
- 'Finish marks the review ready for the agent to execute.',
42
- 'Use -n / --new to start a new review file.',
43
- '',
44
- 'Keys:',
45
- ];
46
- for (const action of ACTIONS) {
47
- lines.push(` ${actionHot(action)} ${action.label}`);
48
- }
49
- lines.push(' ? Help');
50
- lines.push('');
51
- return `${lines.join('\n')}\n`;
29
+ const failUsage = (stderr, reason) => {
30
+ fail(stderr, reason);
31
+ stderr.write(`${USAGE}\n`);
32
+ return 1;
52
33
  };
53
34
 
54
35
  const parseArgv = (argv) => {
55
36
  const paths = [];
56
- const flags = { help: false, version: false, newReview: false };
37
+ let newReview = false;
57
38
  for (const arg of argv) {
58
- const flag = FLAGS[arg];
59
- if (flag) {
60
- flags[flag] = true;
39
+ if (arg === '-n') {
40
+ newReview = true;
61
41
  continue;
62
42
  }
63
43
  if (arg.startsWith('-')) {
@@ -65,10 +45,7 @@ const parseArgv = (argv) => {
65
45
  }
66
46
  paths.push(arg);
67
47
  }
68
- const help = flags.help;
69
- const version = flags.version;
70
- const newReview = flags.newReview;
71
- return { paths, help, version, newReview };
48
+ return { paths, newReview };
72
49
  };
73
50
 
74
51
  const looksLikeRev = (spec) => {
@@ -94,55 +71,84 @@ const resolveScope = (cwd, paths, repo, statSync = fs.statSync) => {
94
71
  return { rev: null, paths };
95
72
  }
96
73
  const resolved = repo.resolveRev ? repo.resolveRev(cwd, first) : null;
97
- if (resolved) return { rev: resolved, paths: paths.slice(1) };
74
+ if (resolved) {
75
+ if (paths.length > 1) {
76
+ throw new Error('use a commit or a path, not both');
77
+ }
78
+ return { rev: resolved, paths: [] };
79
+ }
98
80
  if (looksLikeRev(first)) {
99
81
  throw new Error(`bad revision ${first}`);
100
82
  }
101
83
  return { rev: null, paths };
102
84
  };
103
85
 
104
- const writeHelp = (stdout) => {
105
- stdout.write(helpText());
106
- return 0;
107
- };
108
-
109
- const writeVersion = (stdout, deps) => {
110
- const pkgPath = path.join(__dirname, '..', 'package.json');
111
- const pkg = deps.pkg ?? JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
112
- stdout.write(`${pkg.version}\n`);
113
- return 0;
114
- };
86
+ const isColorEnabled = (stdout, env) => stdout.isTTY === true && !env.NO_COLOR;
115
87
 
116
- const loadSession = (proc, deps, args) => {
88
+ const openLocalSession = (proc, deps, args, env) => {
117
89
  const stdout = proc.stdout;
118
- const env = proc.env ?? {};
119
90
  const repo = deps.repo ?? createGitRepo();
120
91
  const cwd = deps.cwd ?? proc.cwd();
121
92
  const statSync = deps.statSync ?? fs.statSync;
122
93
  const scope = resolveScope(cwd, args.paths, repo, statSync);
123
- const paths = scope.paths;
124
- const rev = scope.rev;
125
- const stdin = proc.stdin;
126
- const color = stdout.isTTY === true && !env.NO_COLOR;
127
- const getSize = deps.getSize;
128
- const startPane = 'diff';
129
- const newReview = args.newReview;
130
94
  const session = new Session({
131
95
  repo,
132
96
  cwd,
133
- paths,
134
- rev,
135
- stdin,
97
+ paths: scope.paths,
98
+ rev: scope.rev,
99
+ stdin: proc.stdin,
100
+ stdout,
101
+ color: isColorEnabled(stdout, env),
102
+ getSize: deps.getSize,
103
+ startPane: 'files',
104
+ newReview: args.newReview,
105
+ });
106
+ session.load();
107
+ return session;
108
+ };
109
+
110
+ const openPullRequestSession = async (proc, deps, args, selected, env) => {
111
+ const stdout = proc.stdout;
112
+ const cwd = deps.cwd ?? proc.cwd();
113
+ const loader = deps.loadPullRequest ?? loadPullRequest;
114
+ const token = githubToken(env);
115
+ const loaded = await loader(selected.pr, {
116
+ cwd,
117
+ paths: selected.paths,
118
+ token,
119
+ fetch: deps.fetch,
120
+ env,
121
+ });
122
+ const change = loaded.change ?? null;
123
+ const repository = change ? change.repository : '';
124
+ const repo = deps.repo ?? createLoadedSource(loaded);
125
+ const session = new Session({
126
+ repo,
127
+ cwd,
128
+ paths: selected.paths,
129
+ stdin: proc.stdin,
136
130
  stdout,
137
- color,
138
- getSize,
139
- startPane,
140
- newReview,
131
+ color: isColorEnabled(stdout, env),
132
+ getSize: deps.getSize,
133
+ startPane: 'files',
134
+ newReview: args.newReview,
135
+ repoName: repository,
136
+ sourceLabel: loaded.sourceLabel,
137
+ change,
141
138
  });
142
139
  session.load();
143
140
  return session;
144
141
  };
145
142
 
143
+ const loadSession = async (proc, deps, args) => {
144
+ const env = proc.env ?? {};
145
+ const selected = selectChangeSource(args.paths);
146
+ if (selected.kind === 'github-pr') {
147
+ return openPullRequestSession(proc, deps, args, selected, env);
148
+ }
149
+ return openLocalSession(proc, deps, args, env);
150
+ };
151
+
146
152
  const run = async (proc, deps = {}) => {
147
153
  const stdout = proc.stdout;
148
154
  const stderr = proc.stderr;
@@ -150,13 +156,11 @@ const run = async (proc, deps = {}) => {
150
156
  try {
151
157
  args = parseArgv(proc.argv.slice(2));
152
158
  } catch (error) {
153
- return fail(stderr, error);
159
+ return failUsage(stderr, error);
154
160
  }
155
- if (args.help) return writeHelp(stdout);
156
- if (args.version) return writeVersion(stdout, deps);
157
161
  let session;
158
162
  try {
159
- session = loadSession(proc, deps, args);
163
+ session = await loadSession(proc, deps, args);
160
164
  } catch (error) {
161
165
  return fail(stderr, error);
162
166
  }
@@ -174,8 +178,8 @@ const run = async (proc, deps = {}) => {
174
178
  module.exports = {
175
179
  run,
176
180
  parseArgv,
177
- helpText,
178
181
  resolveScope,
179
182
  looksLikeRev,
183
+ selectChangeSource,
180
184
  errorMessage,
181
185
  };
package/lib/diff.js CHANGED
@@ -11,22 +11,23 @@ const PAIR_TOKEN_WEIGHT = 0.6;
11
11
  const WORD_REFINE_MIN = 0.6;
12
12
  const TOKEN_RE = /[A-Za-z_$][\w$]*|\d+|[^\s]/g;
13
13
  const PIECE_RE = /[A-Za-z_$][\w$]*|\d+|\s+|./g;
14
- const LINE_MARK = Object.assign(Object.create(null), {
14
+ const LINE_MARK = {
15
15
  add: '+',
16
16
  del: '-',
17
17
  ctx: ' ',
18
- });
19
- const LINE_TYPE = Object.assign(Object.create(null), {
18
+ };
19
+ const LINE_TYPE = {
20
20
  '+': 'add',
21
21
  '-': 'del',
22
22
  ' ': 'ctx',
23
- });
24
- const INDEX_SIDE = Object.assign(Object.create(null), {
23
+ };
24
+ const INDEX_SIDE = {
25
25
  staged: 'new',
26
26
  unstaged: 'old',
27
27
  untracked: 'old',
28
28
  commit: 'old',
29
- });
29
+ pr: 'old',
30
+ };
30
31
 
31
32
  const collapseSpans = (parts) => {
32
33
  const spans = [];
@@ -774,10 +775,10 @@ const formatPatch = (file, hunk, blockId, contextSide) => {
774
775
  return `${parts.join('\n')}\n`;
775
776
  };
776
777
 
777
- const LAYOUT_ALIGN = Object.assign(Object.create(null), {
778
+ const LAYOUT_ALIGN = {
778
779
  mixed: alignPairs,
779
780
  side: alignSplit,
780
- });
781
+ };
781
782
 
782
783
  const windowAroundBlock = (lines, blockId, radius) => {
783
784
  let start = -1;
package/lib/files.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const ORIGIN_ORDER = ['staged', 'unstaged', 'untracked', 'commit'];
3
+ const ORIGIN_ORDER = ['staged', 'unstaged', 'untracked', 'commit', 'pr'];
4
4
 
5
5
  const itemPath = (item) => {
6
6
  if (!item || !item.file) return '';
package/lib/git.js CHANGED
@@ -240,12 +240,36 @@ const revertItem = (top, item) => {
240
240
  }
241
241
  };
242
242
 
243
+ const revertFile = (top, rel, items) => {
244
+ const origins = new Set();
245
+ let isNew = false;
246
+ for (const item of items) {
247
+ origins.add(item.origin);
248
+ if (item.file && item.file.isNew) isNew = true;
249
+ }
250
+ if (isNew) {
251
+ if (origins.has('staged')) {
252
+ requireOk(runGit(['rm', '-f', '--', rel], top));
253
+ return;
254
+ }
255
+ fs.unlinkSync(path.join(top, rel));
256
+ return;
257
+ }
258
+ if (origins.has('untracked') && origins.size === 1) {
259
+ fs.unlinkSync(path.join(top, rel));
260
+ return;
261
+ }
262
+ const args = ['restore', '-s', 'HEAD', '--worktree', '--staged', '--', rel];
263
+ requireOk(runGit(args, top));
264
+ };
265
+
243
266
  const createGitRepo = () => ({
244
267
  load,
245
268
  resolveRev,
246
269
  add: addItem,
247
270
  unstage: unstageItem,
248
271
  revert: revertItem,
272
+ revertFile,
249
273
  });
250
274
 
251
275
  module.exports = {
@@ -256,6 +280,7 @@ module.exports = {
256
280
  addItem,
257
281
  unstageItem,
258
282
  revertItem,
283
+ revertFile,
259
284
  createGitRepo,
260
285
  oneLine,
261
286
  };