reslop 0.2.4 → 0.2.6

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.
Files changed (109) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/LICENSE +1 -1
  3. package/README.md +12 -13
  4. package/bin/reslop.js +3 -8
  5. package/lib/ansi.js +32 -19
  6. package/lib/capabilities.js +8 -12
  7. package/lib/cli.js +40 -74
  8. package/lib/clipboard.js +48 -18
  9. package/lib/dashboard/git.js +128 -0
  10. package/lib/dashboard/model.js +332 -0
  11. package/lib/dashboard/npm.js +193 -0
  12. package/lib/dashboard/tree.js +278 -0
  13. package/lib/deps-diff.js +196 -404
  14. package/lib/deps.js +240 -399
  15. package/lib/detect.js +8 -18
  16. package/lib/diff/align.js +41 -113
  17. package/lib/diff/diff.js +12 -43
  18. package/lib/diff/display.js +25 -36
  19. package/lib/diff/edit.js +6 -20
  20. package/lib/diff/inline.js +138 -141
  21. package/lib/diff/items.js +33 -47
  22. package/lib/diff/parse.js +28 -56
  23. package/lib/diff/patch.js +12 -33
  24. package/lib/diff/unit.js +90 -206
  25. package/lib/editor.js +98 -55
  26. package/lib/files.js +86 -103
  27. package/lib/git-branches.js +254 -416
  28. package/lib/git-deps.js +182 -344
  29. package/lib/git-reword-todo.js +5 -13
  30. package/lib/git-worktree.js +141 -212
  31. package/lib/git.js +21 -49
  32. package/lib/github.js +67 -181
  33. package/lib/gitlab.js +40 -90
  34. package/lib/highlight/bash.js +55 -177
  35. package/lib/highlight/core.js +157 -132
  36. package/lib/highlight/css.js +25 -58
  37. package/lib/highlight/csv.js +19 -37
  38. package/lib/highlight/dart.js +72 -213
  39. package/lib/highlight/dot.js +16 -26
  40. package/lib/highlight/html.js +42 -63
  41. package/lib/highlight/js.js +177 -506
  42. package/lib/highlight/json.js +17 -37
  43. package/lib/highlight/log.js +22 -35
  44. package/lib/highlight/txt.js +9 -22
  45. package/lib/highlight.js +17 -46
  46. package/lib/keys.js +62 -85
  47. package/lib/manifest.js +67 -130
  48. package/lib/npm-commands.js +119 -189
  49. package/lib/remote.js +141 -197
  50. package/lib/render/branches.js +66 -73
  51. package/lib/render/chrome.js +264 -323
  52. package/lib/render/commits.js +187 -500
  53. package/lib/render/dash-activity.js +212 -0
  54. package/lib/render/dash-blocks.js +245 -0
  55. package/lib/render/dash-table.js +290 -0
  56. package/lib/render/dashboard.js +62 -0
  57. package/lib/render/diff.js +123 -228
  58. package/lib/render/files.js +45 -99
  59. package/lib/render/frame.js +129 -183
  60. package/lib/render/notes.js +144 -160
  61. package/lib/render/npm.js +234 -112
  62. package/lib/render/present.js +4 -11
  63. package/lib/render/primitives.js +131 -170
  64. package/lib/render/render.js +5 -14
  65. package/lib/render/report.js +43 -0
  66. package/lib/render/tiles.js +260 -0
  67. package/lib/render/unit.js +15 -28
  68. package/lib/report-model.js +110 -33
  69. package/lib/report-parse.js +56 -38
  70. package/lib/report-render.js +102 -59
  71. package/lib/report-run.js +87 -78
  72. package/lib/report-test.js +256 -141
  73. package/lib/report-tools.js +96 -161
  74. package/lib/review-merge.js +114 -152
  75. package/lib/review.js +115 -189
  76. package/lib/runs.js +225 -0
  77. package/lib/select.js +89 -75
  78. package/lib/session/accessors.js +14 -21
  79. package/lib/session/actions.js +82 -63
  80. package/lib/session/branches.js +193 -247
  81. package/lib/session/change-actions.js +171 -214
  82. package/lib/session/commit.js +280 -342
  83. package/lib/session/compose.js +559 -674
  84. package/lib/session/dashboard.js +385 -0
  85. package/lib/session/dispatch.js +164 -157
  86. package/lib/session/edit-click.js +57 -0
  87. package/lib/session/files-pane.js +85 -53
  88. package/lib/session/items.js +73 -104
  89. package/lib/session/lifecycle.js +403 -541
  90. package/lib/session/load.js +48 -68
  91. package/lib/session/navigation.js +96 -87
  92. package/lib/session/npm.js +409 -345
  93. package/lib/session/ops.js +196 -63
  94. package/lib/session/pointer.js +23 -15
  95. package/lib/session/progress.js +33 -21
  96. package/lib/session/review.js +33 -54
  97. package/lib/session/tasks.js +333 -0
  98. package/lib/session/templates.js +51 -59
  99. package/lib/session/terminal.js +85 -136
  100. package/lib/session/unit.js +3 -4
  101. package/lib/session/update.js +76 -97
  102. package/lib/session/view.js +52 -83
  103. package/lib/session/watch.js +208 -204
  104. package/lib/session.js +112 -237
  105. package/lib/update.js +45 -116
  106. package/lib/utilities.js +67 -89
  107. package/lib/wrap.js +33 -44
  108. package/package.json +1 -1
  109. package/lib/session/todos.js +0 -293
package/CHANGELOG.md CHANGED
@@ -2,6 +2,28 @@
2
2
 
3
3
  ## [Unreleased][unreleased]
4
4
 
5
+ ## [0.2.6][] - 2026-10-02
6
+
7
+ - Add a live dashboard as the start screen
8
+ - Compact related failure contexts in test runnter logs
9
+ - Save the raw command output beside the reduced log
10
+ - Improve test results rendering
11
+ - Update the dashboard from file system events, without timers
12
+ - Record `reslop t` runs under `.log/.runs/` show progress on the dashboard
13
+ - Implement tasks screen: backlog, issues, bugs, features
14
+
15
+ ## [0.2.5][] - 2026-09-30
16
+
17
+ - Remove UI and filesystem polling timers, refresh on events
18
+ - Refresh the commits list when HEAD changes outside reslop
19
+ - Update stage/unstage immediately, git/npm in the background
20
+ - Add editor hotkeys Ctrl+X, Ctrl+C, and Ctrl+V
21
+ - Improve diff calculation
22
+ - Improve npm command and test edit/run
23
+ - Refactor internals: simplify and add classes
24
+ - Hothey "i" add to ignore lists: .gitignore, .npmignore
25
+ - Rename TODO to tasks and improve TUI/TUX
26
+
5
27
  ## [0.2.4][] - 2026-09-28
6
28
 
7
29
  - Add AI instructions to run `npm t` or `reslop t -- node --test`
@@ -141,7 +163,9 @@
141
163
  - Feedback and todos that produce a repair plan for an agent
142
164
  - Intra-line highlighting in unified, mixed, and side-by-side layouts
143
165
 
144
- [unreleased]: https://github.com/tshemsedinov/reslop/compare/v0.2.4...HEAD
166
+ [unreleased]: https://github.com/tshemsedinov/reslop/compare/v0.2.6...HEAD
167
+ [0.2.6]: https://github.com/tshemsedinov/reslop/compare/v0.2.5...v0.2.6
168
+ [0.2.5]: https://github.com/tshemsedinov/reslop/compare/v0.2.4...v0.2.5
145
169
  [0.2.4]: https://github.com/tshemsedinov/reslop/compare/v0.2.3...v0.2.4
146
170
  [0.2.3]: https://github.com/tshemsedinov/reslop/compare/v0.2.1...v0.2.3
147
171
  [0.2.1]: https://github.com/tshemsedinov/reslop/compare/v0.2.0...v0.2.1
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Timur Shemsedinov
3
+ Copyright (c) 2026 Timur Shemsedinov and other contributors (see github)
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
@@ -11,28 +11,25 @@
11
11
 
12
12
  > Turn generated changes into owned changes
13
13
 
14
- Open a change, leave findings and a backlog, and prepare a repair plan
15
- for the agent.
14
+ Open a change, leave findings and a backlog, and prepare a repair plan for the agent.
16
15
 
17
16
  ```text
18
17
  Review → Plan → Repair → Verify
19
18
  ```
20
19
 
20
+ - Live dashboard: files, diffs, tasks, branches, commits, run, and npm, updated in real time
21
21
  - Leave feedback, a project backlog, and in-place code proposals as a repair plan
22
22
  - Review uncommitted git diffs, a given commit, a GitHub PR, or a GitLab MR
23
23
  - File-scope review mode: not just diffs, with editor mode
24
24
  - Stage, unstage, or revert each contiguous block of diff lines
25
25
  - Auto-reload local diffs when files change, keeping the current screen
26
26
  - Import GitHub PR and GitLab MR review comments into the local plan for AI
27
- - Commits list: brief and full, view a commit's diff, commit, amend, fixup, reword, drop, pull, and push
28
- - Branches: checkout, create, rebase, drop, pull, and push
27
+ - Commits list: brief and full, view a commit's diff, commit, amend, apply, edit, fixup, and delete
28
+ - Branches: checkout, create, rebase, delete, pull, and push
29
29
  - npm scripts and bins: run, edit, and reorder; double-click to run; clean logs older than 5 days
30
- - Long lines wrap in the diff view; the editor scrolls horizontally
31
30
  - Reduced test and lint output for agents: failures and a short summary, not passing noise
32
- - Review each npm dependency once across `package.json` and the lockfile
33
- - Propose unused removals, npm audit fixes, and outdated updates as diffs
34
- - Code highlighting and intra-line diffs: unified, mixed, and side-by-side
35
- - Auto-update patch and minor releases; confirm a new major
31
+ - Review, renew, audit and update npm dependencies in `package.json` and the lockfile
32
+ - Auto-update reslop patch and minor releases; confirm a new major
36
33
  - Work with git, npm and fetch in background
37
34
 
38
35
  ## Install
@@ -46,8 +43,8 @@ Requires Node.js `>=18.15.0`.
46
43
 
47
44
  ## Usage
48
45
 
49
- - `reslop` uncommitted diffs in this repository
50
- - `reslop path/file` show only that path or file
46
+ - `reslop` live dashboard of this repository; press a block key to open its screen
47
+ - `reslop path/file` show only that path or file (starts on the file list)
51
48
  - `reslop 7ac260c` show that commit (read-only)
52
49
  - `reslop https://github.com/metarhia/metacom/pull/555` GitHub/GitLab PR/MR
53
50
  - `reslop -n` start a new review even if the latest is still editing
@@ -62,10 +59,12 @@ Prefix each program in an npm script with `reslop t --`. Keep `&&` between those
62
59
  "test": "reslop t -- npm run -s lint && reslop t -- node --test"
63
60
  ```
64
61
 
65
- 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.
62
+ The report is Markdown: failures, diagnostics, and a short summary. Passing results are omitted and similar problems are grouped. The reduced report and the raw output are both saved under `.log/`. The report names the raw file for an agent to read when a detail is missing. Set `RESLOP_OUTPUT=raw` to pass the command through unchanged. The review screen runs scripts in raw mode and applies its own filter once.
66
63
 
67
64
  Run the full check with `npm t`. Run one or more test files with `reslop t -- node --test <files>`. New reviews include those two commands in the agent instructions.
68
65
 
66
+ `reslop t` records each run in `.log/.runs/` so that the dashboard can show commands started by an agent in another terminal.
67
+
69
68
  Reviews go in `.review/YYYY-MM-DD-NN.md` with frontmatter `status`:
70
69
 
71
70
  - `editing`: still writing the review in reslop
@@ -88,5 +87,5 @@ Reviews go in `.review/YYYY-MM-DD-NN.md` with frontmatter `status`:
88
87
 
89
88
  ## License
90
89
 
91
- Copyright (c) 2026 Timur Shemsedinov.
90
+ Copyright (c) 2026 Timur Shemsedinov and other contributors (see github).
92
91
  This is [MIT](./LICENSE) licensed software.
package/bin/reslop.js CHANGED
@@ -16,12 +16,7 @@ const fail = (reason) => {
16
16
  process.exit(1);
17
17
  };
18
18
 
19
- process.on('uncaughtException', (error) => fail(error));
20
- process.on('unhandledRejection', (reason) => fail(reason));
19
+ process.on('uncaughtException', fail);
20
+ process.on('unhandledRejection', fail);
21
21
 
22
- run(process).then(
23
- (code) => {
24
- process.exit(code);
25
- },
26
- (error) => fail(error),
27
- );
22
+ run(process).then((code) => process.exit(code), fail);
package/lib/ansi.js CHANGED
@@ -84,6 +84,14 @@ const DARK_UI = {
84
84
  stagedAddLineBg: [0x26, 0x26, 0x2e],
85
85
  stagedAddCharFg: [0xff, 0xff, 0xff],
86
86
  stagedAddCharBg: [0x4e, 0x4e, 0x58],
87
+ dashBg: [0x00, 0x00, 0x00],
88
+ dashTileBg: [0x26, 0x26, 0x2a],
89
+ dashRowBg: [0x1c, 0x1c, 0x1f],
90
+ dashHeadBg: [0x70, 0x70, 0x7c],
91
+ dashHeadMuted: [0xf2, 0xf2, 0xfa],
92
+ dashDelFg: [0xff, 0xd2, 0xd2],
93
+ dashTrendFg: [0x4a, 0x62, 0x70],
94
+ dashKeyFg: [0xff, 0xff, 0xff],
87
95
  };
88
96
 
89
97
  const DARK_CODE = {
@@ -176,6 +184,14 @@ const LIGHT_UI = {
176
184
  stagedAddLineBg: [0xea, 0xec, 0xef],
177
185
  stagedAddCharFg: [0x0f, 0x14, 0x19],
178
186
  stagedAddCharBg: [0xcc, 0xd1, 0xd7],
187
+ dashBg: [0xff, 0xff, 0xff],
188
+ dashTileBg: [0xe4, 0xe7, 0xeb],
189
+ dashRowBg: [0xd9, 0xdc, 0xe1],
190
+ dashHeadBg: [0xe8, 0xeb, 0xef],
191
+ dashHeadMuted: [0x3e, 0x46, 0x52],
192
+ dashDelFg: [0xd0, 0x3a, 0x44],
193
+ dashTrendFg: [0x8a, 0x98, 0xa6],
194
+ dashKeyFg: [0x00, 0x00, 0x00],
179
195
  };
180
196
 
181
197
  const LIGHT_CODE = {
@@ -233,26 +249,16 @@ const THEME_NAMES = Object.keys(PALETTES);
233
249
  const applyColors = (into, from) => {
234
250
  for (const key of Object.keys(from)) {
235
251
  const src = from[key];
236
- let dst = into[key];
237
- if (!dst) {
238
- dst = [0, 0, 0];
239
- into[key] = dst;
240
- }
252
+ const dst = into[key];
241
253
  dst[0] = src[0];
242
254
  dst[1] = src[1];
243
255
  dst[2] = src[2];
244
256
  }
245
- return into;
246
- };
247
-
248
- const cloneColors = (colors) => {
249
- const into = Array.isArray(colors) ? [] : {};
250
- return applyColors(into, colors);
251
257
  };
252
258
 
253
- const THEME = cloneColors(DARK_UI);
254
- const CODE_FG = cloneColors(DARK_CODE);
255
- const LOG_COL_FG = cloneColors(DARK_LOG_COLS);
259
+ const THEME = structuredClone(DARK_UI);
260
+ const CODE_FG = structuredClone(DARK_CODE);
261
+ const LOG_COL_FG = structuredClone(DARK_LOG_COLS);
256
262
  let currentTheme = 'dark';
257
263
 
258
264
  const setTheme = (name) => {
@@ -339,22 +345,27 @@ const visibleWidth = (text) => {
339
345
  return width;
340
346
  };
341
347
 
342
- const truncateVisible = (text, width) => {
348
+ const truncateVisible = (text, width, mark = '') => {
343
349
  if (width <= 0) return '';
344
- if (visibleWidth(text) <= width) return text;
345
350
  const plain = stripAnsi(text);
351
+ if (visibleWidth(plain) <= width) return text;
352
+ const markW = mark ? visibleWidth(mark) : 0;
353
+ const fits = markW > 0 && markW <= width;
354
+ const limit = fits ? width - markW : width;
346
355
  const parts = graphemes(plain);
347
356
  let used = 0;
348
357
  let out = '';
349
358
  for (const ch of parts) {
350
359
  const w = graphemeWidth(ch, used);
351
- if (used + w > width) break;
360
+ if (used + w > limit) break;
352
361
  out += ch;
353
362
  used += w;
354
363
  }
355
- return out;
364
+ return fits ? `${out}${mark}` : out;
356
365
  };
357
366
 
367
+ const trimVisible = (text, width) => truncateVisible(text, width, '…');
368
+
358
369
  const csiLength = (text, index) => {
359
370
  if (text[index] !== ESC || text[index + 1] !== '[') return 0;
360
371
  let at = index + 2;
@@ -459,7 +470,7 @@ const foregroundOn = (text, bgRgb) => {
459
470
  const kept = keepFgParams(sgrParams(seq));
460
471
  const reset = kept.includes(0);
461
472
  const rest = kept.filter((code) => code !== 0);
462
- if (reset) out += `${ESC}[0m`;
473
+ if (reset) out += RESET;
463
474
  out += ground;
464
475
  if (rest.length) out += `${ESC}[${rest.join(';')}m`;
465
476
  }
@@ -487,6 +498,8 @@ module.exports = {
487
498
  graphemeWidth,
488
499
  visibleWidth,
489
500
  truncateVisible,
501
+ trimVisible,
502
+ firstGrapheme,
490
503
  clipAnsi,
491
504
  foregroundOn,
492
505
  };
@@ -50,12 +50,14 @@ const BRANCH_METHODS = [
50
50
  'pushAsync',
51
51
  ];
52
52
 
53
+ const VIEW_CAPS = { read: true, changes: false, branches: false, review: true };
54
+
53
55
  const SOURCE_CAPS = {
54
56
  local: { read: true, changes: true, branches: true, review: true },
55
- commit: { read: true, changes: false, branches: false, review: true },
56
- pr: { read: true, changes: false, branches: false, review: true },
57
- mr: { read: true, changes: false, branches: false, review: true },
58
- readonly: { read: true, changes: false, branches: false, review: true },
57
+ commit: VIEW_CAPS,
58
+ pr: VIEW_CAPS,
59
+ mr: VIEW_CAPS,
60
+ readonly: VIEW_CAPS,
59
61
  };
60
62
 
61
63
  const withReadOnly = (caps, readOnly) => {
@@ -88,21 +90,15 @@ const sessionCapabilities = (options) => {
88
90
  if (options.capabilities) return withReadOnly(options.capabilities, readOnly);
89
91
  if (options.rev) return capabilitiesFor('commit', { readOnly });
90
92
  const source = options.change && options.change.source;
91
- if (source && SOURCE_CAPS[source]) {
92
- return capabilitiesFor(source, { readOnly });
93
- }
93
+ if (SOURCE_CAPS[source]) return capabilitiesFor(source, { readOnly });
94
94
  const repoCaps = options.repo && options.repo.capabilities;
95
95
  if (repoCaps) return withReadOnly(repoCaps, readOnly);
96
- return capabilitiesFor(readOnly ? 'readonly' : 'local');
96
+ return capabilitiesFor('local', { readOnly });
97
97
  };
98
98
 
99
99
  module.exports = {
100
- READ_METHODS,
101
- CHANGE_METHODS,
102
- BRANCH_METHODS,
103
100
  SOURCE_CAPS,
104
101
  capabilitiesFor,
105
- withReadOnly,
106
102
  attachCapabilities,
107
103
  sessionCapabilities,
108
104
  };
package/lib/cli.js CHANGED
@@ -44,12 +44,8 @@ const REMOTE_CHANGE = {
44
44
  },
45
45
  };
46
46
 
47
- const errorMessage = (reason) => {
48
- if (reason instanceof Error) return reason.message;
49
- if (reason === null || reason === undefined) return '';
50
- if (typeof reason === 'string') return reason;
51
- return `${reason}`;
52
- };
47
+ const errorMessage = (reason) =>
48
+ reason instanceof Error ? reason.message : `${reason ?? ''}`;
53
49
 
54
50
  const fail = (stderr, reason) => {
55
51
  stderr.write(`reslop: ${errorMessage(reason)}\n`);
@@ -62,81 +58,44 @@ const parseArgv = (argv) => {
62
58
  let readOnly = false;
63
59
  let theme = 'dark';
64
60
  for (const arg of argv) {
65
- if (arg === '-n') {
66
- newReview = true;
67
- continue;
68
- }
69
- if (arg === '-r') {
70
- readOnly = true;
71
- continue;
72
- }
73
- if (arg === '-light') {
74
- theme = 'light';
75
- continue;
76
- }
77
- if (arg.startsWith('-')) {
78
- throw new Error(`unknown option ${arg}`);
79
- }
80
- paths.push(arg);
61
+ if (arg === '-n') newReview = true;
62
+ else if (arg === '-r') readOnly = true;
63
+ else if (arg === '-light') theme = 'light';
64
+ else if (arg.startsWith('-')) throw new Error(`unknown option ${arg}`);
65
+ else paths.push(arg);
81
66
  }
82
67
  return { paths, newReview, readOnly, theme };
83
68
  };
84
69
 
85
- const looksLikeRev = (spec) => {
86
- if (!spec) return false;
87
- if (HEX_REV.test(spec)) return true;
88
- if (HEAD_REV.test(spec)) return true;
89
- return RANGE_REV.test(spec);
90
- };
70
+ const looksLikeRev = (spec) =>
71
+ HEX_REV.test(spec) || HEAD_REV.test(spec) || RANGE_REV.test(spec);
91
72
 
92
73
  const resolveScope = async (cwd, paths, repo) => {
93
74
  if (!paths.length) return { rev: null, paths };
94
75
  const first = paths[0];
95
- if (await exists(path.resolve(cwd, first))) {
96
- return { rev: null, paths };
97
- }
76
+ if (await exists(path.resolve(cwd, first))) return { rev: null, paths };
98
77
  const resolved = repo.resolveRev(cwd, first);
99
78
  if (resolved) {
100
- if (paths.length > 1) {
101
- throw new Error('use a commit or a path, not both');
102
- }
79
+ if (paths.length > 1) throw new Error('use a commit or a path, not both');
103
80
  return { rev: resolved, paths: [] };
104
81
  }
105
- if (looksLikeRev(first)) {
106
- throw new Error(`bad revision ${first}`);
107
- }
82
+ if (looksLikeRev(first)) throw new Error(`bad revision ${first}`);
108
83
  return { rev: null, paths };
109
84
  };
110
85
 
111
- const sessionOptions = (proc, deps, args, env, extra) => ({
112
- cwd: deps.cwd ?? proc.cwd(),
113
- stdin: proc.stdin,
114
- stdout: proc.stdout,
115
- color: proc.stdout.isTTY === true && !env.NO_COLOR,
116
- startPane: 'files',
117
- newReview: args.newReview,
118
- readOnly: args.readOnly,
119
- audit: true,
120
- ...extra,
121
- });
122
-
123
- const openLocalSession = async (proc, deps, args, selected, env) => {
124
- const cwd = deps.cwd ?? proc.cwd();
86
+ const localOptions = async (deps, args, selected, cwd) => {
125
87
  const rawRepo = deps.repo ?? createGitRepo();
126
88
  const scope = await resolveScope(cwd, selected.paths, rawRepo);
127
89
  const kind = scope.rev ? 'commit' : 'local';
128
90
  const capabilities = capabilitiesFor(kind, { readOnly: args.readOnly });
129
- const repo = attachCapabilities(rawRepo, capabilities);
130
- const extra = {
131
- repo,
91
+ const direct = scope.rev || scope.paths.length;
92
+ return {
93
+ repo: attachCapabilities(rawRepo, capabilities),
132
94
  paths: scope.paths,
133
95
  rev: scope.rev,
134
96
  capabilities,
97
+ startPane: direct ? 'files' : 'dashboard',
135
98
  };
136
- const options = sessionOptions(proc, deps, args, env, extra);
137
- const session = new Session(options);
138
- session.ensureRepo();
139
- return session;
140
99
  };
141
100
 
142
101
  const remoteRepo = (deps, selected, env, cwd) => {
@@ -160,28 +119,34 @@ const remoteRepo = (deps, selected, env, cwd) => {
160
119
  });
161
120
  };
162
121
 
163
- const openRemoteSession = (proc, deps, args, selected, env) => {
122
+ const remoteOptions = (deps, args, selected, cwd, env) => ({
123
+ repo: remoteRepo(deps, selected, env, cwd),
124
+ paths: selected.paths,
125
+ capabilities: capabilitiesFor(selected.kind, { readOnly: args.readOnly }),
126
+ });
127
+
128
+ const loadSession = async (proc, deps, args) => {
129
+ const env = proc.env ?? {};
164
130
  const cwd = deps.cwd ?? proc.cwd();
165
- const capabilities = capabilitiesFor(selected.kind, {
131
+ const selected = selectChangeSource(args.paths);
132
+ const extra = REMOTE_CHANGE[selected.kind]
133
+ ? remoteOptions(deps, args, selected, cwd, env)
134
+ : await localOptions(deps, args, selected, cwd);
135
+ const session = new Session({
136
+ cwd,
137
+ stdin: proc.stdin,
138
+ stdout: proc.stdout,
139
+ color: proc.stdout.isTTY === true && !env.NO_COLOR,
140
+ startPane: 'files',
141
+ newReview: args.newReview,
166
142
  readOnly: args.readOnly,
143
+ audit: true,
144
+ ...extra,
167
145
  });
168
- const repo = remoteRepo(deps, selected, env, cwd);
169
- const extra = { repo, paths: selected.paths, capabilities };
170
- const options = sessionOptions(proc, deps, args, env, extra);
171
- const session = new Session(options);
172
146
  session.ensureRepo();
173
147
  return session;
174
148
  };
175
149
 
176
- const loadSession = async (proc, deps, args) => {
177
- const env = proc.env ?? {};
178
- const selected = selectChangeSource(args.paths);
179
- if (REMOTE_CHANGE[selected.kind]) {
180
- return openRemoteSession(proc, deps, args, selected, env);
181
- }
182
- return openLocalSession(proc, deps, args, selected, env);
183
- };
184
-
185
150
  const runUi = async (session, proc) => {
186
151
  const code = await session.startUi();
187
152
  if (session.loadError) return fail(proc.stderr, session.loadError);
@@ -196,7 +161,7 @@ const run = async (proc, deps = {}) => {
196
161
  const stderr = proc.stderr;
197
162
  let args;
198
163
  try {
199
- args = parseArgv(proc.argv.slice(2));
164
+ args = parseArgv(argv);
200
165
  } catch (error) {
201
166
  fail(stderr, error);
202
167
  stderr.write(`${USAGE}\n`);
@@ -228,5 +193,6 @@ module.exports = {
228
193
  errorMessage,
229
194
  parseArgv,
230
195
  resolveScope,
196
+ loadSession,
231
197
  run,
232
198
  };
package/lib/clipboard.js CHANGED
@@ -6,40 +6,70 @@ const { spawnSync } = childProcess;
6
6
  const ansi = require('./ansi.js');
7
7
  const { ESC } = ansi;
8
8
  const utilities = require('./utilities.js');
9
- const { clipTools, spawnBase } = utilities;
9
+ const { spawnBase } = utilities;
10
10
 
11
11
  const CLIP_TIMEOUT_MS = 1000;
12
12
 
13
- const osc52 = (text) => {
14
- const b64 = Buffer.from(text, 'utf8').toString('base64');
15
- return `${ESC}]52;c;${b64}\x07`;
13
+ const COPY_TOOLS = {
14
+ win32: [{ cmd: 'clip', args: [] }],
15
+ darwin: [{ cmd: 'pbcopy', args: [] }],
16
+ unix: [
17
+ { cmd: 'wl-copy', args: [] },
18
+ { cmd: 'xclip', args: ['-selection', 'clipboard'] },
19
+ { cmd: 'xsel', args: ['--clipboard', '--input'] },
20
+ ],
16
21
  };
17
22
 
18
- const runClip = (cmd, args, text) => {
19
- const result = spawnSync(
20
- cmd,
21
- args,
22
- spawnBase({
23
- input: text,
24
- encoding: 'utf8',
25
- timeout: CLIP_TIMEOUT_MS,
26
- }),
23
+ const PASTE_TOOLS = {
24
+ win32: [
25
+ { cmd: 'powershell', args: ['-NoProfile', '-Command', 'Get-Clipboard'] },
26
+ ],
27
+ darwin: [{ cmd: 'pbpaste', args: [] }],
28
+ unix: [
29
+ { cmd: 'wl-paste', args: ['--no-newline'] },
30
+ { cmd: 'xclip', args: ['-selection', 'clipboard', '-o'] },
31
+ { cmd: 'xsel', args: ['--clipboard', '--output'] },
32
+ ],
33
+ };
34
+
35
+ let copied = '';
36
+
37
+ const platformTools = (tools) => tools[process.platform] ?? tools.unix;
38
+
39
+ const runTool = (tool, input) =>
40
+ spawnSync(
41
+ tool.cmd,
42
+ tool.args,
43
+ spawnBase({ input, encoding: 'utf8', timeout: CLIP_TIMEOUT_MS }),
27
44
  );
28
- if (result.error) return false;
29
- return result.status === 0;
45
+
46
+ const osc52 = (text) => {
47
+ const b64 = Buffer.from(text, 'utf8').toString('base64');
48
+ return `${ESC}]52;c;${b64}\x07`;
30
49
  };
31
50
 
32
51
  const copyText = (text, stdout) => {
33
52
  if (!text) return false;
53
+ copied = text;
34
54
  let ok = false;
35
55
  if (stdout && stdout.write) {
36
56
  stdout.write(osc52(text));
37
57
  ok = true;
38
58
  }
39
- for (const tool of clipTools()) {
40
- if (runClip(tool.cmd, tool.args, text)) return true;
59
+ for (const tool of platformTools(COPY_TOOLS)) {
60
+ const result = runTool(tool, text);
61
+ if (!result.error && result.status === 0) return true;
41
62
  }
42
63
  return ok;
43
64
  };
44
65
 
45
- module.exports = { osc52, copyText };
66
+ const pasteText = () => {
67
+ for (const tool of platformTools(PASTE_TOOLS)) {
68
+ const result = runTool(tool);
69
+ if (result.error || result.status !== 0) continue;
70
+ if (result.stdout) return result.stdout;
71
+ }
72
+ return copied;
73
+ };
74
+
75
+ module.exports = { osc52, copyText, pasteText };
@@ -0,0 +1,128 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const worktree = require('../git-worktree.js');
7
+ const { runGitAsync } = worktree;
8
+
9
+ const UNIT = '\x1f';
10
+ const RECENT_COUNT = 8;
11
+ const BRANCH_FORMAT = [
12
+ '%(HEAD)',
13
+ '%(refname:short)',
14
+ '%(objectname:short)',
15
+ '%(committerdate:unix)',
16
+ '%(upstream:short)',
17
+ '%(upstream:track)',
18
+ '%(subject)',
19
+ ].join('%09');
20
+ const LAST_FORMAT = ['%H', '%h', '%ct', '%s'].join('%x1f');
21
+ const PROBES = {
22
+ total: ['rev-list', '--count', 'HEAD'],
23
+ recent: ['log', `--max-count=${RECENT_COUNT}`, `--format=${LAST_FORMAT}`],
24
+ refs: ['for-each-ref', `--format=${BRANCH_FORMAT}`, 'refs/heads'],
25
+ dir: ['rev-parse', '--absolute-git-dir'],
26
+ };
27
+ const TRACK_AHEAD = /ahead (\d+)/;
28
+ const TRACK_BEHIND = /behind (\d+)/;
29
+
30
+ const gitText = async (top, args) => {
31
+ try {
32
+ const result = await runGitAsync(args, top);
33
+ return result.status === 0 ? result.stdout.trim() : '';
34
+ } catch {
35
+ return '';
36
+ }
37
+ };
38
+
39
+ const countOf = (text) => {
40
+ const value = parseInt(text, 10);
41
+ return Number.isFinite(value) ? value : 0;
42
+ };
43
+
44
+ const msOf = (seconds) => (Number.isFinite(seconds) ? seconds * 1000 : 0);
45
+
46
+ const parseLines = (text, parse) => text.split('\n').map(parse).filter(Boolean);
47
+
48
+ const parseTrack = (text) => {
49
+ const raw = `${text ?? ''}`;
50
+ const ahead = TRACK_AHEAD.exec(raw);
51
+ const behind = TRACK_BEHIND.exec(raw);
52
+ return {
53
+ ahead: ahead ? Number(ahead[1]) : 0,
54
+ behind: behind ? Number(behind[1]) : 0,
55
+ gone: raw.includes('gone'),
56
+ };
57
+ };
58
+
59
+ const parseBranch = (line) => {
60
+ const parts = line.split('\t');
61
+ const name = (parts[1] ?? '').trim();
62
+ if (!name) return null;
63
+ return {
64
+ name,
65
+ current: parts[0].includes('*'),
66
+ sha: (parts[2] ?? '').trim(),
67
+ at: msOf(Number(parts[3])),
68
+ upstream: (parts[4] ?? '').trim(),
69
+ ...parseTrack(parts[5]),
70
+ subject: parts.slice(6).join('\t').trim(),
71
+ };
72
+ };
73
+
74
+ const parseCommit = (line) => {
75
+ const parts = line.split(UNIT);
76
+ if (parts.length < 4 || !parts[0]) return null;
77
+ return {
78
+ sha: parts[0],
79
+ short: parts[1],
80
+ at: msOf(Number(parts[2])),
81
+ subject: parts.slice(3).join(UNIT).trim(),
82
+ };
83
+ };
84
+
85
+ const readNumber = (file) => {
86
+ try {
87
+ return countOf(fs.readFileSync(file, 'utf8'));
88
+ } catch {
89
+ return 0;
90
+ }
91
+ };
92
+
93
+ const readStep = (dir, step, total) => ({
94
+ step: readNumber(path.join(dir, step)),
95
+ total: readNumber(path.join(dir, total)),
96
+ });
97
+
98
+ const rebaseState = (gitDir) => {
99
+ if (!gitDir) return null;
100
+ const merge = path.join(gitDir, 'rebase-merge');
101
+ if (fs.existsSync(merge)) return readStep(merge, 'msgnum', 'end');
102
+ const apply = path.join(gitDir, 'rebase-apply');
103
+ if (fs.existsSync(apply)) return readStep(apply, 'next', 'last');
104
+ return null;
105
+ };
106
+
107
+ const readGitSummary = async (top) => {
108
+ const names = Object.keys(PROBES);
109
+ const read = (name) => gitText(top, PROBES[name]);
110
+ const texts = await Promise.all(names.map(read));
111
+ const probes = {};
112
+ for (let i = 0; i < names.length; i++) probes[names[i]] = texts[i];
113
+ const branches = parseLines(probes.refs, parseBranch);
114
+ const current = branches.find((entry) => entry.current);
115
+ const recent = parseLines(probes.recent, parseCommit);
116
+ return {
117
+ branch: current ? current.name : '',
118
+ rebase: rebaseState(probes.dir),
119
+ commits: {
120
+ total: countOf(probes.total),
121
+ last: recent[0] ?? null,
122
+ recent,
123
+ },
124
+ branches,
125
+ };
126
+ };
127
+
128
+ module.exports = { readGitSummary, parseTrack };