critique 0.0.1 → 0.0.3

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/AGENTS.md CHANGED
@@ -6,7 +6,6 @@ IMPORTANT! before starting every task ALWAYS read opentui docs with `curl -s htt
6
6
 
7
7
  ALWAYS!
8
8
 
9
-
10
9
  ## bun
11
10
 
12
11
  NEVER run bun run index.tsx. You cannot directly run the tui app. it will hang. instead ask me to do so.
@@ -24,6 +23,7 @@ Try to never use useEffect if possible. usually you can move logic directly in e
24
23
  ## Rules
25
24
 
26
25
  - keep types as close as possible to rayacst
26
+ - if you need Node.js apis import the namesapce and not the named exports: `import fs from 'fs'` and not `import { writeFileSync } from 'fs'`
27
27
  - DO NOT use as any. instead try to understand how to fix the types in other ways
28
28
  - to implement compound components like `List.Item` first define the type of List, using a interface, then use : to implement it and add compound components later using . and omitting the props types given they are already typed by the interface, here is an example
29
29
  - DO NOT use console.log. only use logger.log instead
@@ -53,7 +53,12 @@ https://gitchamber.com/repos/repos/vercel/ai/main/files
53
53
 
54
54
  use gitchamber to read the .md files using curl
55
55
 
56
-
57
56
  ## researching opentui patterns
58
57
 
59
58
  you can read more examples of opentui react code using gitchamber by listing and reading files from the correct endpoint: https://gitchamber.com/repos/sst/opentui/main/files?glob=packages/react/examples/**
59
+
60
+ ## changelog
61
+
62
+ after any meaningful change update CHANGELOG.md with the version number and the list of changes made. in concise bullet points
63
+
64
+ before updating the changelog bump the package.json version field first. NEVER do major bumps. NEVER publish yourself
package/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # 0.0.3
2
+
3
+ - Add `pick` command to selectively apply files from another branch
4
+ - Use autocomplete multiselect for file selection
5
+ - Support conflict detection and 3-way merge
6
+
7
+ # 0.0.2
8
+
9
+ - Add support for pick command
package/bun.lock CHANGED
@@ -4,6 +4,7 @@
4
4
  "": {
5
5
  "name": "react",
6
6
  "dependencies": {
7
+ "@clack/prompts": "^1.0.0-alpha.6",
7
8
  "@opentui/core": "^0.1.26",
8
9
  "@opentui/react": "^0.1.26",
9
10
  "@shikijs/langs": "^3.13.0",
@@ -23,6 +24,10 @@
23
24
  "packages": {
24
25
  "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
25
26
 
27
+ "@clack/core": ["@clack/core@1.0.0-alpha.6", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-eG5P45+oShFG17u9I1DJzLkXYB1hpUgTLi32EfsMjSHLEqJUR8BOBCVFkdbUX2g08eh/HCi6UxNGpPhaac1LAA=="],
28
+
29
+ "@clack/prompts": ["@clack/prompts@1.0.0-alpha.6", "", { "dependencies": { "@clack/core": "1.0.0-alpha.6", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-75NCtYOgDHVBE2nLdKPTDYOaESxO0GLAKC7INREp5VbS988Xua1u+588VaGlcvXiLc/kSwc25Cd+4PeTSpY6QQ=="],
30
+
26
31
  "@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="],
27
32
 
28
33
  "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="],
@@ -225,6 +230,8 @@
225
230
 
226
231
  "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="],
227
232
 
233
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
234
+
228
235
  "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="],
229
236
 
230
237
  "planck": ["planck@1.4.2", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-mNbhnV3g8X2rwGxzcesjmN8BDA6qfXgQxXVMkWau9MCRlQY0RLNEkyHlVp6yFy/X6qrzAXyNONCnZ1cGDLrNew=="],
@@ -261,6 +268,8 @@
261
268
 
262
269
  "simple-xml-to-json": ["simple-xml-to-json@1.2.3", "", {}, "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA=="],
263
270
 
271
+ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
272
+
264
273
  "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
265
274
 
266
275
  "stage-js": ["stage-js@1.0.0-alpha.17", "", {}, "sha512-AzlMO+t51v6cFvKZ+Oe9DJnL1OXEH5s9bEy6di5aOrUpcP7PCzI/wIeXF0u3zg0L89gwnceoKxrLId0ZpYnNXw=="],
package/package.json CHANGED
@@ -2,11 +2,12 @@
2
2
  "name": "critique",
3
3
  "module": "src/diff.tsx",
4
4
  "type": "module",
5
- "version": "0.0.1",
5
+ "version": "0.0.3",
6
6
  "private": false,
7
7
  "bin": "./src/cli.tsx",
8
8
  "scripts": {
9
9
  "cli": "bun --watch src/cli.tsx",
10
+ "prepublishonly": "tsc",
10
11
  "example": "bun --watch src/example.tsx"
11
12
  },
12
13
  "devDependencies": {
@@ -14,6 +15,7 @@
14
15
  "typescript": "^5.9.3"
15
16
  },
16
17
  "dependencies": {
18
+ "@clack/prompts": "^1.0.0-alpha.6",
17
19
  "@opentui/core": "^0.1.26",
18
20
  "@opentui/react": "^0.1.26",
19
21
  "@shikijs/langs": "^3.13.0",
package/src/cli.tsx CHANGED
@@ -1,17 +1,129 @@
1
1
  #!/usr/bin/env bun
2
2
  import { cac } from "cac";
3
- import { parsePatch } from "diff";
4
- import { render, useOnResize, useTerminalDimensions } from "@opentui/react";
5
- import * as React from "react";
6
- import { execSync } from "child_process";
7
3
  import {
8
- ErrorBoundary,
9
- FileEditPreviewTitle,
10
- FileEditPreview,
11
- } from "./diff.tsx";
4
+ render,
5
+ useKeyboard,
6
+ useOnResize,
7
+ useRenderer,
8
+ useTerminalDimensions,
9
+ } from "@opentui/react";
10
+ import * as React from "react";
11
+ import { exec, execSync } from "child_process";
12
+ import { promisify } from "util";
13
+ import { MacOSScrollAccel } from "@opentui/core";
14
+ import fs from "fs";
15
+ import { tmpdir } from "os";
16
+ import { join } from "path";
17
+ import * as p from "@clack/prompts";
18
+
19
+ const execAsync = promisify(exec);
12
20
 
13
21
  const cli = cac("critique");
14
22
 
23
+ class ScrollAcceleration {
24
+ public multiplier: number = 1;
25
+ private macosAccel: MacOSScrollAccel;
26
+ constructor() {
27
+ this.macosAccel = new MacOSScrollAccel();
28
+ }
29
+ tick(delta: number) {
30
+ return this.macosAccel.tick(delta) * this.multiplier;
31
+ }
32
+ reset() {
33
+ this.macosAccel.reset();
34
+ // this.multiplier = 1;
35
+ }
36
+ }
37
+
38
+ interface AppProps {
39
+ parsedFiles: Array<{
40
+ oldFileName?: string;
41
+ newFileName?: string;
42
+ hunks: any[];
43
+ }>;
44
+ }
45
+
46
+ function App({ parsedFiles }: AppProps) {
47
+ const { width: initialWidth } = useTerminalDimensions();
48
+ const [width, setWidth] = React.useState(initialWidth);
49
+ const [scrollAcceleration] = React.useState(() => new ScrollAcceleration());
50
+
51
+ useOnResize(
52
+ React.useCallback((newWidth: number) => {
53
+ setWidth(newWidth);
54
+ }, []),
55
+ );
56
+ const useSplitView = width >= 100;
57
+
58
+ const renderer = useRenderer();
59
+
60
+ useKeyboard((key) => {
61
+ if (key.name === "z" && key.ctrl) {
62
+ renderer.console.toggle();
63
+ }
64
+ if (key.option) {
65
+ console.log(key);
66
+ if (key.eventType === "release") {
67
+ scrollAcceleration.multiplier = 1;
68
+ } else {
69
+ scrollAcceleration.multiplier = 10;
70
+ }
71
+ }
72
+ });
73
+
74
+ const { FileEditPreviewTitle, FileEditPreview } = require("./diff.tsx");
75
+
76
+ return (
77
+ <box
78
+ key={String(useSplitView)}
79
+ style={{ flexDirection: "column", height: "100%", padding: 1 }}
80
+ >
81
+ <scrollbox
82
+ scrollAcceleration={scrollAcceleration}
83
+ style={{
84
+ flexGrow: 1,
85
+ rootOptions: {
86
+ backgroundColor: "transparent",
87
+ border: false,
88
+ },
89
+ scrollbarOptions: {
90
+ showArrows: false,
91
+ trackOptions: {
92
+ foregroundColor: "#4a4a4a",
93
+ backgroundColor: "transparent",
94
+ },
95
+ },
96
+ }}
97
+ focused
98
+ >
99
+ <box style={{ flexDirection: "column" }}>
100
+ {parsedFiles.map((file, idx) => (
101
+ <box
102
+ key={idx}
103
+ style={{
104
+ flexDirection: "column",
105
+ marginBottom: idx < parsedFiles.length - 1 ? 2 : 0,
106
+ }}
107
+ >
108
+ <FileEditPreviewTitle
109
+ filePath={file.newFileName || file.oldFileName || "unknown"}
110
+ hunks={file.hunks}
111
+ />
112
+ <box paddingTop={1} />
113
+ <FileEditPreview
114
+ hunks={file.hunks}
115
+ paddingLeft={0}
116
+ splitView={useSplitView}
117
+ filePath={file.newFileName || file.oldFileName || ""}
118
+ />
119
+ </box>
120
+ ))}
121
+ </box>
122
+ </scrollbox>
123
+ </box>
124
+ );
125
+ }
126
+
15
127
  cli
16
128
  .command(
17
129
  "[ref]",
@@ -20,95 +132,196 @@ cli
20
132
  .option("--staged", "Show staged changes")
21
133
  .option("--commit <ref>", "Show changes from a specific commit")
22
134
  .action(async (ref, options) => {
23
- let gitDiff: string;
24
-
25
135
  try {
26
- if (options.staged) {
27
- gitDiff = execSync("git diff --cached", { encoding: "utf-8" });
28
- } else if (options.commit) {
29
- gitDiff = execSync(`git show ${options.commit}`, { encoding: "utf-8" });
30
- } else if (ref) {
31
- gitDiff = execSync(`git show ${ref}`, { encoding: "utf-8" });
32
- } else {
33
- gitDiff = execSync("git diff", { encoding: "utf-8" });
136
+ const gitCommand = (() => {
137
+ if (options.staged) return "git diff --cached --no-prefix";
138
+ if (options.commit) return `git show ${options.commit} --no-prefix`;
139
+ if (ref) return `git show ${ref} --no-prefix`;
140
+ return "git diff --no-prefix";
141
+ })();
142
+
143
+ const [{ stdout: gitDiff }, diffModule, { parsePatch }] =
144
+ await Promise.all([
145
+ execAsync(gitCommand, { encoding: "utf-8" }),
146
+ import("./diff.tsx"),
147
+ import("diff"),
148
+ ]);
149
+
150
+ if (!gitDiff.trim()) {
151
+ console.log("No changes to display");
152
+ process.exit(0);
153
+ }
154
+
155
+ const parsedFiles = parsePatch(gitDiff);
156
+
157
+ if (parsedFiles.length === 0) {
158
+ console.log("No changes to display");
159
+ process.exit(0);
34
160
  }
161
+
162
+ const { ErrorBoundary } = diffModule;
163
+
164
+ await render(
165
+ React.createElement(
166
+ ErrorBoundary,
167
+ null,
168
+ React.createElement(App, { parsedFiles }),
169
+ ),
170
+ );
35
171
  } catch (error) {
36
172
  console.error("Error getting git diff:", error);
37
173
  process.exit(1);
38
174
  }
175
+ });
39
176
 
40
- if (!gitDiff.trim()) {
41
- console.log("No changes to display");
177
+ cli
178
+ .command("difftool <local> <remote>", "Git difftool integration")
179
+ .action(async (local: string, remote: string) => {
180
+ if (!process.stdout.isTTY) {
181
+ execSync(`git diff --no-ext-diff "${local}" "${remote}"`, {
182
+ stdio: "inherit",
183
+ });
42
184
  process.exit(0);
43
185
  }
44
186
 
45
- const parsedFiles = parsePatch(gitDiff);
187
+ try {
188
+ const [localContent, remoteContent, diffModule, { structuredPatch }] =
189
+ await Promise.all([
190
+ fs.readFileSync(local, "utf-8"),
191
+ fs.readFileSync(remote, "utf-8"),
192
+ import("./diff.tsx"),
193
+ import("diff"),
194
+ ]);
46
195
 
47
- if (parsedFiles.length === 0) {
48
- console.log("No changes to display");
49
- process.exit(0);
196
+ const patch = structuredPatch(
197
+ local,
198
+ remote,
199
+ localContent,
200
+ remoteContent,
201
+ "",
202
+ "",
203
+ );
204
+
205
+ if (patch.hunks.length === 0) {
206
+ console.log("No changes to display");
207
+ process.exit(0);
208
+ }
209
+
210
+ const { ErrorBoundary } = diffModule;
211
+
212
+ await render(
213
+ React.createElement(
214
+ ErrorBoundary,
215
+ null,
216
+ React.createElement(App, { parsedFiles: [patch] }),
217
+ ),
218
+ );
219
+ } catch (error) {
220
+ console.error("Error displaying diff:", error);
221
+ process.exit(1);
50
222
  }
223
+ });
224
+
225
+ cli
226
+ .command(
227
+ "pick <branch>",
228
+ "Pick files from another branch to apply to HEAD (experimental)",
229
+ )
230
+ .action(async (branch: string) => {
231
+ try {
232
+ const { stdout: currentBranch } = await execAsync(
233
+ "git branch --show-current",
234
+ );
235
+ const current = currentBranch.trim();
236
+
237
+ if (current === branch) {
238
+ p.log.error("Cannot pick from the same branch");
239
+ process.exit(1);
240
+ }
51
241
 
52
- function App() {
53
- const { width: initialWidth } = useTerminalDimensions();
54
- const [width, setWidth] = React.useState(initialWidth);
242
+ const { stdout: branchExists } = await execAsync(
243
+ `git rev-parse --verify ${branch}`,
244
+ { encoding: "utf-8" },
245
+ ).catch(() => ({ stdout: "" }));
55
246
 
56
- useOnResize(
57
- React.useCallback((newWidth: number) => {
58
- setWidth(newWidth);
59
- }, []),
247
+ if (!branchExists.trim()) {
248
+ p.log.error(`Branch "${branch}" does not exist`);
249
+ process.exit(1);
250
+ }
251
+
252
+ const { stdout: diffOutput } = await execAsync(
253
+ `git diff --name-only HEAD ${branch}`,
254
+ { encoding: "utf-8" },
60
255
  );
61
- const useSplitView = width >= 100;
62
-
63
- return (
64
- <box key={String(useSplitView)} style={{ flexDirection: "column", height: "100%", padding: 1 }}>
65
- <scrollbox
66
- style={{
67
- flexGrow: 1,
68
- rootOptions: {
69
- backgroundColor: "transparent",
70
- border: false,
71
- },
72
- scrollbarOptions: {
73
- showArrows: false,
74
- trackOptions: {
75
- foregroundColor: "#4a4a4a",
76
- backgroundColor: "transparent",
77
- },
78
- },
79
- }}
80
- focused
81
- >
82
- <box style={{ flexDirection: "column" }}>
83
- {parsedFiles.map((file, idx) => (
84
- <box
85
- key={idx}
86
- style={{
87
- flexDirection: "column",
88
- marginBottom: idx < parsedFiles.length - 1 ? 2 : 0,
89
- }}
90
- >
91
- <FileEditPreviewTitle
92
- filePath={file.newFileName || file.oldFileName || "unknown"}
93
- hunks={file.hunks}
94
- />
95
- <box paddingTop={1} />
96
- <FileEditPreview
97
- hunks={file.hunks}
98
- paddingLeft={0}
99
- splitView={useSplitView}
100
- />
101
- </box>
102
- ))}
103
- </box>
104
- </scrollbox>
105
- </box>
256
+
257
+ const files = diffOutput
258
+ .trim()
259
+ .split("\n")
260
+ .filter((f) => f);
261
+
262
+ if (files.length === 0) {
263
+ p.log.info("No differences found between branches");
264
+ process.exit(0);
265
+ }
266
+
267
+ const selectedFiles = await p.autocompleteMultiselect({
268
+ message: `Select files to pick from "${branch}":`,
269
+ options: files.map((file) => ({
270
+ value: file,
271
+ label: file,
272
+ })),
273
+ required: false,
274
+ });
275
+
276
+ if (p.isCancel(selectedFiles)) {
277
+ p.cancel("Operation cancelled.");
278
+ process.exit(0);
279
+ }
280
+
281
+ if (!selectedFiles || selectedFiles.length === 0) {
282
+ p.log.info("No files selected");
283
+ process.exit(0);
284
+ }
285
+
286
+ const { stdout: patchData } = await execAsync(
287
+ `git diff HEAD ${branch} -- ${selectedFiles.join(" ")}`,
288
+ { encoding: "utf-8" },
106
289
  );
107
- }
108
290
 
109
- await render(
110
- React.createElement(ErrorBoundary, null, React.createElement(App)),
111
- );
291
+ const patchFile = join(tmpdir(), `critique-pick-${Date.now()}.patch`);
292
+ fs.writeFileSync(patchFile, patchData);
293
+
294
+ try {
295
+ execSync(`git apply --3way "${patchFile}"`, { stdio: "pipe" });
296
+ } catch {
297
+ execSync(`git apply "${patchFile}"`, { stdio: "inherit" });
298
+ }
299
+
300
+ fs.unlinkSync(patchFile);
301
+
302
+ const { stdout: conflictFiles } = await execAsync(
303
+ "git diff --name-only --diff-filter=U",
304
+ { encoding: "utf-8" },
305
+ );
306
+
307
+ const conflicts = conflictFiles
308
+ .trim()
309
+ .split("\n")
310
+ .filter((f) => f);
311
+
312
+ if (conflicts.length > 0) {
313
+ p.log.warn(`Applied with conflicts in ${conflicts.length} file(s):`);
314
+ conflicts.forEach((file) => p.log.message(` - ${file}`));
315
+ } else {
316
+ p.log.success(`Applied changes from ${selectedFiles.length} file(s)`);
317
+ }
318
+ process.exit(0);
319
+ } catch (error) {
320
+ p.log.error(
321
+ `Error: ${error instanceof Error ? error.message : String(error)}`,
322
+ );
323
+ process.exit(1);
324
+ }
112
325
  });
113
326
 
114
327
  cli.help();