reslop 0.0.1 → 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,7 +2,24 @@
2
2
 
3
3
  ## [Unreleased][unreleased]
4
4
 
5
- ## [0.0.1][] - 2026-01-01
5
+ ## [0.1.1][] - 2026-09-10
6
6
 
7
- [unreleased]: https://github.com/tshemsedinov/reslop/compare/v0.0.1...HEAD
8
- [0.0.1]: https://github.com/tshemsedinov/reslop/releases/tag/v0.0.1
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
15
+
16
+ ### Added
17
+
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
22
+
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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2017-2026 Metarhia contributors
3
+ Copyright (c) 2026 Timur Shemsedinov
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,7 +1,70 @@
1
- # reslop
1
+ # reslop — Review diff and plan changes
2
2
 
3
- Interactive terminal code review git diff with intra-line highlighting.
3
+ > Turn generated changes into owned changes
4
4
 
5
- ## License & Contributors
5
+ ```text
6
+ Review → Plan → Repair → Verify
7
+ ```
6
8
 
7
- Copyright (c) 2026 Metarhia contributors. [MIT licensed](./LICENSE).
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.
13
+ - Leave feedback, add todos.
14
+ - Generate a plan the agent executes.
15
+
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.
19
+
20
+ Intra-line highlighting paints the words that actually changed in a
21
+ stronger red/green. A close edit inside one word still marks only
22
+ those characters.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ npm i -g reslop
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ ```bash
33
+ reslop
34
+ reslop -n
35
+ reslop path/file
36
+ reslop 7ac260c
37
+ reslop https://github.com/metarhia/metacom/pull/555
38
+ ```
39
+
40
+ Reviews go in `.review/YYYY-MM-DD-NN.md` with frontmatter `status`:
41
+
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]`
46
+
47
+ ## Keys and buttons
48
+
49
+ | Key | Action |
50
+ | ----------- | --------------------------------------- |
51
+ | `a` | Stage this block (`git add`) |
52
+ | `u` | Unstage this block (keep worktree) |
53
+ | `r` | Restore this block to the last commit |
54
+ | `s` | Skip (leave unstaged, drop from review) |
55
+ | `←` | Previous remaining block |
56
+ | `→` | Next remaining block |
57
+ | `l` | File list (git status of this scope) |
58
+ | `Esc` | Quit file list or a tool |
59
+ | `m` | Cycle unified / mixed / side-by-side |
60
+ | `f` | Feedback on this diff block |
61
+ | `t` | New todo for this file |
62
+ | `⌫` / `Del` | Remove the selected todo |
63
+ | `⏎` | Open the selected file |
64
+ | `q` | Quit |
65
+ | `?` | Help |
66
+
67
+ ## License
68
+
69
+ Copyright (c) 2026 Timur Shemsedinov.
70
+ This is [MIT](./LICENSE) licensed software.
package/bin/reslop.js ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { run, errorMessage } = require('../lib/cli.js');
5
+ const { LEAVE_TERM } = require('../lib/session.js');
6
+
7
+ const fail = (reason) => {
8
+ try {
9
+ process.stdout.write(LEAVE_TERM);
10
+ } catch {}
11
+ process.stderr.write(`reslop: ${errorMessage(reason)}\n`);
12
+ process.exit(1);
13
+ };
14
+
15
+ process.on('uncaughtException', (error) => fail(error));
16
+ process.on('unhandledRejection', (reason) => fail(reason));
17
+
18
+ run(process).then(
19
+ (code) => {
20
+ process.exit(code);
21
+ },
22
+ (error) => fail(error),
23
+ );
package/lib/ansi.js ADDED
@@ -0,0 +1,231 @@
1
+ 'use strict';
2
+
3
+ const SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
4
+
5
+ const ESC = String.fromCodePoint(0x1b);
6
+ const ANSI_RE = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, 'g');
7
+ const RESET = `${ESC}[0m`;
8
+ const BOLD = `${ESC}[1m`;
9
+ const EL = `${ESC}[K`;
10
+
11
+ const WIDE_CP = [
12
+ 0x1100, 0x115f, 0x231a, 0x231b, 0x2329, 0x232a, 0x23e9, 0x23ec, 0x23f0,
13
+ 0x23f0, 0x23f3, 0x23f3, 0x25fd, 0x25fe, 0x2614, 0x2615, 0x2630, 0x2637,
14
+ 0x2648, 0x2653, 0x267f, 0x267f, 0x268a, 0x268f, 0x2693, 0x2693, 0x26a1,
15
+ 0x26a1, 0x26aa, 0x26ab, 0x26bd, 0x26be, 0x26c4, 0x26c5, 0x26ce, 0x26ce,
16
+ 0x26d4, 0x26d4, 0x26ea, 0x26ea, 0x26f2, 0x26f3, 0x26f5, 0x26f5, 0x26fa,
17
+ 0x26fa, 0x26fd, 0x26fd, 0x2705, 0x2705, 0x270a, 0x270b, 0x2728, 0x2728,
18
+ 0x274c, 0x274c, 0x274e, 0x274e, 0x2753, 0x2755, 0x2757, 0x2757, 0x2795,
19
+ 0x2797, 0x27b0, 0x27b0, 0x27bf, 0x27bf, 0x2b1b, 0x2b1c, 0x2b50, 0x2b50,
20
+ 0x2b55, 0x2b55, 0x2e80, 0x2e99, 0x2e9b, 0x2ef3, 0x2f00, 0x2fd5, 0x2ff0,
21
+ 0x303e, 0x3041, 0x3096, 0x3099, 0x30ff, 0x3105, 0x312f, 0x3131, 0x318e,
22
+ 0x3190, 0x31e5, 0x31ef, 0x321e, 0x3220, 0x3247, 0x3250, 0xa48c, 0xa490,
23
+ 0xa4c6, 0xa960, 0xa97c, 0xac00, 0xd7a3, 0xf900, 0xfaff, 0xfe10, 0xfe19,
24
+ 0xfe30, 0xfe52, 0xfe54, 0xfe66, 0xfe68, 0xfe6b, 0xff01, 0xff60, 0xffe0,
25
+ 0xffe6, 0x16fe0, 0x16fe4, 0x16ff0, 0x16ff1, 0x17000, 0x187f7, 0x18800,
26
+ 0x18cd5, 0x18cff, 0x18d08, 0x1aff0, 0x1aff3, 0x1aff5, 0x1affb, 0x1affd,
27
+ 0x1affe, 0x1b000, 0x1b122, 0x1b132, 0x1b132, 0x1b150, 0x1b152, 0x1b155,
28
+ 0x1b155, 0x1b164, 0x1b167, 0x1b170, 0x1b2fb, 0x1d300, 0x1d356, 0x1d360,
29
+ 0x1d376, 0x1f004, 0x1f004, 0x1f0cf, 0x1f0cf, 0x1f18e, 0x1f18e, 0x1f191,
30
+ 0x1f19a, 0x1f200, 0x1f202, 0x1f210, 0x1f23b, 0x1f240, 0x1f248, 0x1f250,
31
+ 0x1f251, 0x1f260, 0x1f265, 0x1f300, 0x1f320, 0x1f32d, 0x1f335, 0x1f337,
32
+ 0x1f37c, 0x1f37e, 0x1f393, 0x1f3a0, 0x1f3ca, 0x1f3cf, 0x1f3d3, 0x1f3e0,
33
+ 0x1f3f0, 0x1f3f4, 0x1f3f4, 0x1f3f8, 0x1f43e, 0x1f440, 0x1f440, 0x1f442,
34
+ 0x1f4fc, 0x1f4ff, 0x1f53d, 0x1f54b, 0x1f54e, 0x1f550, 0x1f567, 0x1f57a,
35
+ 0x1f57a, 0x1f595, 0x1f596, 0x1f5a4, 0x1f5a4, 0x1f5fb, 0x1f64f, 0x1f680,
36
+ 0x1f6c5, 0x1f6cc, 0x1f6cc, 0x1f6d0, 0x1f6d2, 0x1f6d5, 0x1f6d7, 0x1f6dc,
37
+ 0x1f6df, 0x1f6eb, 0x1f6ec, 0x1f6f4, 0x1f6fc, 0x1f7e0, 0x1f7eb, 0x1f7f0,
38
+ 0x1f7f0, 0x1f90c, 0x1f93a, 0x1f93c, 0x1f945, 0x1f947, 0x1f9ff, 0x1fa70,
39
+ 0x1fa7c, 0x1fa80, 0x1fa89, 0x1fa8f, 0x1fac6, 0x1face, 0x1fadc, 0x1fadf,
40
+ 0x1fae9, 0x1faf0, 0x1faf8, 0x20000, 0x2fffd, 0x30000, 0x3fffd,
41
+ ];
42
+
43
+ const THEME = {
44
+ delLineFg: [0xcc, 0x7a, 0x7a],
45
+ delLineBg: [0x3a, 0x1a, 0x1a],
46
+ delCharFg: [0xff, 0xff, 0xff],
47
+ delCharBg: [0x75, 0x22, 0x22],
48
+ addLineFg: [0x7a, 0xcc, 0x8a],
49
+ addLineBg: [0x14, 0x28, 0x14],
50
+ addCharFg: [0xff, 0xff, 0xff],
51
+ addCharBg: [0x1a, 0x4c, 0x26],
52
+ ctxFg: [0x8a, 0x8a, 0x8a],
53
+ ctxBg: [0x12, 0x12, 0x18],
54
+ splitGutterFg: [0x18, 0x18, 0x24],
55
+ splitGutterBg: [0x18, 0x18, 0x24],
56
+ headerFg: [0x3a, 0x3a, 0x3a],
57
+ headerBg: [0xe6, 0xe6, 0xee],
58
+ headerChromeFg: [0x5a, 0x5a, 0x5a],
59
+ headerRepoFg: [0x3a, 0x3a, 0x3a],
60
+ headerDirFg: [0x6e, 0x6e, 0x6e],
61
+ headerSlashFg: [0x8a, 0x8a, 0x8a],
62
+ headerFileFg: [0x4e, 0x4e, 0x4e],
63
+ headerMetaFg: [0x5a, 0x5a, 0x5a],
64
+ chromeFg: [0xd0, 0xd0, 0xe0],
65
+ chromeBg: [0x18, 0x18, 0x24],
66
+ noteBg: [0x24, 0x24, 0x36],
67
+ buttonFg: [0x6a, 0x6a, 0x7e],
68
+ buttonBg: [0x2a, 0x2a, 0x40],
69
+ buttonHotFg: [0xff, 0xff, 0xff],
70
+ checkFg: [0xf4, 0xf4, 0xfc],
71
+ checkBg: [0x52, 0x52, 0x74],
72
+ checkDoneFg: [0xf0, 0xff, 0xf4],
73
+ checkDoneBg: [0x22, 0x58, 0x34],
74
+ errorFg: [0xff, 0x80, 0x80],
75
+ warnFg: [0xe8, 0xc8, 0x4a],
76
+ mutedFg: [0x7a, 0x7a, 0x8a],
77
+ stagedDelLineFg: [0xa0, 0xa0, 0xa8],
78
+ stagedDelLineBg: [0x20, 0x20, 0x26],
79
+ stagedDelCharFg: [0xf0, 0xf0, 0xf4],
80
+ stagedDelCharBg: [0x44, 0x44, 0x4e],
81
+ stagedAddLineFg: [0xb0, 0xb0, 0xb8],
82
+ stagedAddLineBg: [0x26, 0x26, 0x2e],
83
+ stagedAddCharFg: [0xff, 0xff, 0xff],
84
+ stagedAddCharBg: [0x4e, 0x4e, 0x58],
85
+ };
86
+
87
+ const CODE_FG = {
88
+ keyword: [197, 134, 192],
89
+ control: [197, 134, 192],
90
+ storage: [86, 156, 214],
91
+ literal: [86, 156, 214],
92
+ string: [206, 145, 120],
93
+ template: [206, 145, 120],
94
+ escape: [209, 105, 105],
95
+ interpolation: [156, 220, 254],
96
+ number: [181, 206, 168],
97
+ comment: [106, 153, 85],
98
+ function: [220, 220, 170],
99
+ method: [220, 220, 170],
100
+ className: [78, 201, 176],
101
+ type: [78, 201, 176],
102
+ interface: [78, 201, 176],
103
+ property: [156, 220, 254],
104
+ variable: [156, 220, 254],
105
+ parameter: [156, 220, 254],
106
+ constant: [79, 193, 255],
107
+ operator: [212, 212, 212],
108
+ punct: [212, 212, 212],
109
+ regex: [209, 105, 105],
110
+ decorator: [220, 220, 170],
111
+ tag: [86, 156, 214],
112
+ attr: [156, 220, 254],
113
+ plain: [212, 212, 212],
114
+ punctDim: [128, 128, 128],
115
+ logDate: [210, 210, 220],
116
+ logError: [230, 90, 90],
117
+ logWarn: [230, 180, 80],
118
+ logInfo: [100, 170, 230],
119
+ logDebug: [140, 160, 180],
120
+ };
121
+
122
+ const LOG_COL_FG = [
123
+ [210, 210, 220],
124
+ [120, 180, 220],
125
+ [180, 160, 220],
126
+ [140, 200, 160],
127
+ [220, 180, 120],
128
+ [220, 140, 160],
129
+ [160, 200, 200],
130
+ [200, 170, 140],
131
+ ];
132
+
133
+ const codeFg = (style) => {
134
+ if (typeof style !== 'string') return CODE_FG.plain;
135
+ if (Object.hasOwn(CODE_FG, style)) return CODE_FG[style];
136
+ if (style.startsWith('logCol')) {
137
+ const i = parseInt(style.slice(6), 10);
138
+ return LOG_COL_FG[i % LOG_COL_FG.length];
139
+ }
140
+ return CODE_FG.plain;
141
+ };
142
+
143
+ const fg = (rgb) => `${ESC}[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m`;
144
+
145
+ const bg = (rgb) => `${ESC}[48;2;${rgb[0]};${rgb[1]};${rgb[2]}m`;
146
+
147
+ const seq = (fgRgb, bgRgb) => `${fg(fgRgb)}${bg(bgRgb)}`;
148
+
149
+ const paint = (text, fgRgb, bgRgb, color, bold) => {
150
+ if (!color || text === '') return text;
151
+ const mark = bold ? BOLD : '';
152
+ return `${mark}${seq(fgRgb, bgRgb)}${text}${RESET}`;
153
+ };
154
+
155
+ const stripAnsi = (text) => text.replace(ANSI_RE, '');
156
+
157
+ const graphemes = (text) => {
158
+ const parts = [];
159
+ for (const { segment } of SEGMENTER.segment(text)) parts.push(segment);
160
+ return parts;
161
+ };
162
+
163
+ const isWide = (cp) => {
164
+ let lo = 0;
165
+ let hi = WIDE_CP.length >> 1;
166
+ while (lo < hi) {
167
+ const mid = (lo + hi) >> 1;
168
+ const start = WIDE_CP[mid << 1];
169
+ const end = WIDE_CP[(mid << 1) + 1];
170
+ if (cp < start) hi = mid;
171
+ else if (cp > end) lo = mid + 1;
172
+ else return true;
173
+ }
174
+ return false;
175
+ };
176
+
177
+ const graphemeWidth = (ch, col) => {
178
+ if (ch === '\t') return 8 - (col % 8);
179
+ if (ch === '\n' || ch === '\r') return 0;
180
+ const cp = ch.codePointAt(0);
181
+ if (cp <= 0x1f || cp === 0x7f) return 0;
182
+ if (cp === 0x200d) return 0;
183
+ if (cp >= 0xfe00 && cp <= 0xfe0f) return 0;
184
+ if (isWide(cp)) return 2;
185
+ return 1;
186
+ };
187
+
188
+ const visibleWidth = (text) => {
189
+ const plain = stripAnsi(text);
190
+ let width = 0;
191
+ for (const ch of graphemes(plain)) {
192
+ width += graphemeWidth(ch, width);
193
+ }
194
+ return width;
195
+ };
196
+
197
+ const truncateVisible = (text, width) => {
198
+ if (width <= 0) return '';
199
+ if (visibleWidth(text) <= width) return text;
200
+ const plain = stripAnsi(text);
201
+ const parts = graphemes(plain);
202
+ let used = 0;
203
+ let out = '';
204
+ for (const ch of parts) {
205
+ const w = graphemeWidth(ch, used);
206
+ if (used + w > width) break;
207
+ out += ch;
208
+ used += w;
209
+ }
210
+ return out;
211
+ };
212
+
213
+ module.exports = {
214
+ ESC,
215
+ RESET,
216
+ BOLD,
217
+ EL,
218
+ THEME,
219
+ CODE_FG,
220
+ LOG_COL_FG,
221
+ codeFg,
222
+ fg,
223
+ bg,
224
+ seq,
225
+ paint,
226
+ stripAnsi,
227
+ graphemes,
228
+ graphemeWidth,
229
+ visibleWidth,
230
+ truncateVisible,
231
+ };
package/lib/api.js ADDED
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ const ansi = require('./ansi.js');
4
+ const diff = require('./diff.js');
5
+ const git = require('./git.js');
6
+ const keys = require('./keys.js');
7
+ const render = require('./render.js');
8
+ const highlight = require('./highlight.js');
9
+ const detect = require('./detect.js');
10
+ const github = require('./github.js');
11
+ const source = require('./source.js');
12
+ const { Session } = require('./session.js');
13
+ const cli = require('./cli.js');
14
+
15
+ module.exports = {
16
+ THEME: ansi.THEME,
17
+ CODE_FG: ansi.CODE_FG,
18
+ ACTIONS: keys.ACTIONS,
19
+ diffChars: diff.diffChars,
20
+ parseDiff: diff.parseDiff,
21
+ splitHunk: diff.splitHunk,
22
+ displayLines: diff.displayLines,
23
+ formatPatch: diff.formatPatch,
24
+ itemsFromFiles: diff.itemsFromFiles,
25
+ createGitRepo: git.createGitRepo,
26
+ parseGithubPrUrl: github.parseGithubPrUrl,
27
+ loadPullRequest: github.loadPullRequest,
28
+ selectChangeSource: source.selectChangeSource,
29
+ decodeChunk: keys.decodeChunk,
30
+ hitAction: keys.hitAction,
31
+ renderFrame: render.renderFrame,
32
+ tokenize: highlight.tokenize,
33
+ overlayTokens: highlight.overlayTokens,
34
+ detectLang: detect.detectLang,
35
+ run: cli.run,
36
+ Session,
37
+ };
package/lib/cli.js ADDED
@@ -0,0 +1,185 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const { createGitRepo } = require('./git.js');
7
+ const { githubToken, loadPullRequest } = require('./github.js');
8
+ const { selectChangeSource, createLoadedSource } = require('./source.js');
9
+ const { Session } = require('./session.js');
10
+
11
+ const HEX_REV = /^[0-9a-f]{4,40}$/i;
12
+ const HEAD_REV = /^HEAD([~^].*)?$/i;
13
+ const RANGE_REV = /[~^]/;
14
+
15
+ const USAGE = 'Usage: reslop [-n] [path | commit | pr-url]';
16
+
17
+ const errorMessage = (reason) => {
18
+ if (reason instanceof Error) return reason.message;
19
+ if (reason === null || reason === undefined) return '';
20
+ if (typeof reason === 'string') return reason;
21
+ return `${reason}`;
22
+ };
23
+
24
+ const fail = (stderr, reason) => {
25
+ stderr.write(`reslop: ${errorMessage(reason)}\n`);
26
+ return 1;
27
+ };
28
+
29
+ const failUsage = (stderr, reason) => {
30
+ fail(stderr, reason);
31
+ stderr.write(`${USAGE}\n`);
32
+ return 1;
33
+ };
34
+
35
+ const parseArgv = (argv) => {
36
+ const paths = [];
37
+ let newReview = false;
38
+ for (const arg of argv) {
39
+ if (arg === '-n') {
40
+ newReview = true;
41
+ continue;
42
+ }
43
+ if (arg.startsWith('-')) {
44
+ throw new Error(`unknown option ${arg}`);
45
+ }
46
+ paths.push(arg);
47
+ }
48
+ return { paths, newReview };
49
+ };
50
+
51
+ const looksLikeRev = (spec) => {
52
+ if (!spec) return false;
53
+ if (HEX_REV.test(spec)) return true;
54
+ if (HEAD_REV.test(spec)) return true;
55
+ return RANGE_REV.test(spec);
56
+ };
57
+
58
+ const pathExists = (cwd, rel, statSync) => {
59
+ try {
60
+ statSync(path.resolve(cwd, rel));
61
+ return true;
62
+ } catch {
63
+ return false;
64
+ }
65
+ };
66
+
67
+ const resolveScope = (cwd, paths, repo, statSync = fs.statSync) => {
68
+ if (!paths.length) return { rev: null, paths };
69
+ const first = paths[0];
70
+ if (pathExists(cwd, first, statSync)) {
71
+ return { rev: null, paths };
72
+ }
73
+ const resolved = repo.resolveRev ? repo.resolveRev(cwd, first) : null;
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
+ }
80
+ if (looksLikeRev(first)) {
81
+ throw new Error(`bad revision ${first}`);
82
+ }
83
+ return { rev: null, paths };
84
+ };
85
+
86
+ const isColorEnabled = (stdout, env) => stdout.isTTY === true && !env.NO_COLOR;
87
+
88
+ const openLocalSession = (proc, deps, args, env) => {
89
+ const stdout = proc.stdout;
90
+ const repo = deps.repo ?? createGitRepo();
91
+ const cwd = deps.cwd ?? proc.cwd();
92
+ const statSync = deps.statSync ?? fs.statSync;
93
+ const scope = resolveScope(cwd, args.paths, repo, statSync);
94
+ const session = new Session({
95
+ repo,
96
+ cwd,
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,
130
+ stdout,
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,
138
+ });
139
+ session.load();
140
+ return session;
141
+ };
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
+
152
+ const run = async (proc, deps = {}) => {
153
+ const stdout = proc.stdout;
154
+ const stderr = proc.stderr;
155
+ let args;
156
+ try {
157
+ args = parseArgv(proc.argv.slice(2));
158
+ } catch (error) {
159
+ return failUsage(stderr, error);
160
+ }
161
+ let session;
162
+ try {
163
+ session = await loadSession(proc, deps, args);
164
+ } catch (error) {
165
+ return fail(stderr, error);
166
+ }
167
+ if (!session.items.length) {
168
+ stdout.write('nothing to review\n');
169
+ return 0;
170
+ }
171
+ if (!proc.stdin.isTTY || !stdout.isTTY) {
172
+ stderr.write('reslop: interactive terminal required\n');
173
+ return 1;
174
+ }
175
+ return session.startUi();
176
+ };
177
+
178
+ module.exports = {
179
+ run,
180
+ parseArgv,
181
+ resolveScope,
182
+ looksLikeRev,
183
+ selectChangeSource,
184
+ errorMessage,
185
+ };
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ const { spawnSync } = require('node:child_process');
4
+
5
+ const { ESC } = require('./ansi.js');
6
+
7
+ 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
+
14
+ const osc52 = (text) => {
15
+ const b64 = Buffer.from(text, 'utf8').toString('base64');
16
+ return `${ESC}]52;c;${b64}\x07`;
17
+ };
18
+
19
+ const runClip = (cmd, args, text) => {
20
+ const result = spawnSync(cmd, args, {
21
+ input: text,
22
+ encoding: 'utf8',
23
+ timeout: CLIP_TIMEOUT_MS,
24
+ });
25
+ if (result.error) return false;
26
+ return result.status === 0;
27
+ };
28
+
29
+ const copyText = (text, stdout) => {
30
+ if (!text) return false;
31
+ let ok = false;
32
+ if (stdout && stdout.write) {
33
+ stdout.write(osc52(text));
34
+ ok = true;
35
+ }
36
+ for (const tool of CLIP_TOOLS) {
37
+ if (runClip(tool.cmd, tool.args, text)) return true;
38
+ }
39
+ return ok;
40
+ };
41
+
42
+ module.exports = { osc52, copyText, runClip };