opencode-worktree 0.3.0 → 0.3.2

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/README.md CHANGED
@@ -43,7 +43,7 @@ opencode-worktree /path/to/your/repo
43
43
 
44
44
  - `Up`/`Down` or `j`/`k`: navigate
45
45
  - `Enter`: open selected worktree in opencode (or toggle selection in delete mode)
46
- - `o`: open worktree folder in file manager (Finder/Explorer)
46
+ - `o`: open worktree folder in file manager or custom editor (configurable)
47
47
  - `d`: enter multi-select delete mode (press again to confirm deletion)
48
48
  - `n`: create new worktree
49
49
  - `c`: edit configuration (post-create hooks)
@@ -67,7 +67,10 @@ When you first run `opencode-worktree` in a repository without a configuration f
67
67
 
68
68
  ### Editing configuration
69
69
 
70
- Press `c` at any time to edit your configuration. Currently, this allows you to set or modify the post-create hook command.
70
+ Press `c` at any time to edit your configuration. You can configure:
71
+
72
+ - **Post-create hook**: Command to run after creating a worktree (e.g., `npm install`)
73
+ - **Open command**: Custom command for opening worktree folders (e.g., `webstorm`, `code`)
71
74
 
72
75
  ### Post-create hooks
73
76
 
@@ -95,6 +98,31 @@ The hook output is streamed to the TUI in real-time. If the hook fails, you can
95
98
  }
96
99
  ```
97
100
 
101
+ ### Custom open command
102
+
103
+ Use a custom command when pressing `o` to open worktree folders. Useful for opening in your preferred IDE.
104
+
105
+ ```json
106
+ {
107
+ "openCommand": "webstorm"
108
+ }
109
+ ```
110
+
111
+ **Examples:**
112
+
113
+ ```json
114
+ {
115
+ "openCommand": "code"
116
+ }
117
+ ```
118
+
119
+ ```json
120
+ {
121
+ "postCreateHook": "npm install",
122
+ "openCommand": "webstorm"
123
+ }
124
+ ```
125
+
98
126
  ## Update notifications
99
127
 
100
128
  When a new version is published to npm, the CLI will show a non-intrusive update message on the next run.
@@ -5,7 +5,6 @@ import path from 'node:path';
5
5
  import os from 'node:os';
6
6
  import { spawn } from 'node:child_process';
7
7
  import { fileURLToPath } from 'node:url';
8
- import updateNotifier from 'update-notifier';
9
8
 
10
9
  const __filename = fileURLToPath(import.meta.url);
11
10
  const __dirname = path.dirname(__filename);
@@ -13,8 +12,6 @@ const pkg = JSON.parse(
13
12
  fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
14
13
  );
15
14
 
16
- updateNotifier({ pkg }).notify({ isGlobal: true });
17
-
18
15
  const detectPlatformAndArch = () => {
19
16
  let platform;
20
17
  switch (os.platform()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-worktree",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "TUI for managing git worktrees with opencode integration.",
@@ -48,6 +48,7 @@
48
48
  "devDependencies": {
49
49
  "@types/bun": "^1.2.0",
50
50
  "@types/node": "^24.2.0",
51
+ "@types/update-notifier": "^6.0.8",
51
52
  "typescript": "^5.9.3"
52
53
  }
53
54
  }
package/script/build.ts CHANGED
@@ -107,6 +107,8 @@ const buildBinary = async (target: Target) => {
107
107
  OTUI_TREE_SITTER_WORKER_PATH: JSON.stringify(
108
108
  bunfsRoot + workerRelativePath,
109
109
  ),
110
+ __PACKAGE_VERSION__: JSON.stringify(version),
111
+ __PACKAGE_NAME__: JSON.stringify(packageName),
110
112
  },
111
113
  });
112
114
  } catch (error) {
package/src/cli.ts CHANGED
@@ -1,9 +1,19 @@
1
1
  import { runApp } from "./ui.js";
2
2
 
3
+ // Build-time injected constants (defined in script/build.ts)
4
+ // Use typeof check to provide fallbacks for dev mode (bun run dev)
5
+ declare const __PACKAGE_VERSION__: string | undefined;
6
+ declare const __PACKAGE_NAME__: string | undefined;
7
+
8
+ const pkg = {
9
+ name: typeof __PACKAGE_NAME__ !== "undefined" ? __PACKAGE_NAME__ : "opencode-worktree",
10
+ version: typeof __PACKAGE_VERSION__ !== "undefined" ? __PACKAGE_VERSION__ : "dev",
11
+ };
12
+
3
13
  // Accept optional path argument: opencode-worktree [path]
4
14
  const targetPath = process.argv[2] || process.cwd();
5
15
 
6
- runApp(targetPath).catch((error: unknown) => {
16
+ runApp(targetPath, pkg).catch((error: unknown) => {
7
17
  console.error("Failed to start OpenTUI worktree selector.");
8
18
  console.error(error);
9
19
  process.exit(1);
package/src/config.ts CHANGED
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
 
4
4
  export type Config = {
5
5
  postCreateHook?: string;
6
+ openCommand?: string; // Custom command to open worktree folder (e.g., "webstorm", "code")
6
7
  };
7
8
 
8
9
  const CONFIG_FILENAME = ".opencode-worktree.json";
@@ -46,6 +47,10 @@ export const loadRepoConfig = (repoRoot: string): Config => {
46
47
  config.postCreateHook = parsed.postCreateHook;
47
48
  }
48
49
 
50
+ if (typeof parsed.openCommand === "string") {
51
+ config.openCommand = parsed.openCommand;
52
+ }
53
+
49
54
  return config;
50
55
  } catch {
51
56
  // If we can't read or parse the config, return empty
package/src/opencode.ts CHANGED
@@ -20,23 +20,32 @@ export const launchOpenCode = (cwd: string): void => {
20
20
  };
21
21
 
22
22
  /**
23
- * Open a path in the system file manager (Finder on macOS, xdg-open on Linux, explorer on Windows)
23
+ * Open a path in the system file manager or with a custom command
24
+ * If customCommand is provided, uses that instead of the system default
24
25
  */
25
- export const openInFileManager = (path: string): boolean => {
26
- const platform = process.platform;
26
+ export const openInFileManager = (path: string, customCommand?: string): boolean => {
27
27
  let command: string;
28
28
  let args: string[];
29
29
 
30
- if (platform === "darwin") {
31
- command = "open";
32
- args = [path];
33
- } else if (platform === "win32") {
34
- command = "explorer";
30
+ if (customCommand) {
31
+ // Use custom command (e.g., "webstorm", "code")
32
+ command = customCommand;
35
33
  args = [path];
36
34
  } else {
37
- // Linux and others
38
- command = "xdg-open";
39
- args = [path];
35
+ // Use system default
36
+ const platform = process.platform;
37
+
38
+ if (platform === "darwin") {
39
+ command = "open";
40
+ args = [path];
41
+ } else if (platform === "win32") {
42
+ command = "explorer";
43
+ args = [path];
44
+ } else {
45
+ // Linux and others
46
+ command = "xdg-open";
47
+ args = [path];
48
+ }
40
49
  }
41
50
 
42
51
  try {
package/src/ui.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  type KeyEvent,
11
11
  type SelectOption,
12
12
  } from "@opentui/core";
13
+ import updateNotifier from "update-notifier";
13
14
  import { basename } from "node:path";
14
15
  import {
15
16
  createWorktree,
@@ -45,14 +46,22 @@ const CONFIRM_UNLINK_VALUE: ConfirmAction = "unlink";
45
46
  const CONFIRM_DELETE_VALUE: ConfirmAction = "delete";
46
47
  const CONFIRM_CANCEL_VALUE: ConfirmAction = "cancel";
47
48
 
48
- export const runApp = async (targetPath: string): Promise<void> => {
49
+ export type PackageInfo = {
50
+ name: string;
51
+ version: string;
52
+ };
53
+
54
+ export const runApp = async (
55
+ targetPath: string,
56
+ pkg?: PackageInfo,
57
+ ): Promise<void> => {
49
58
  const renderer = await createCliRenderer({
50
59
  exitOnCtrlC: false,
51
60
  targetFps: 30,
52
61
  });
53
62
 
54
63
  renderer.setBackgroundColor("transparent");
55
- new WorktreeSelector(renderer, targetPath);
64
+ new WorktreeSelector(renderer, targetPath, pkg);
56
65
  };
57
66
 
58
67
  class WorktreeSelector {
@@ -60,6 +69,7 @@ class WorktreeSelector {
60
69
  private statusText: TextRenderable;
61
70
  private instructions: TextRenderable;
62
71
  private title: TextRenderable;
72
+ private versionNotice: TextRenderable | null = null;
63
73
 
64
74
  private inputContainer: BoxRenderable | null = null;
65
75
  private branchInput: InputRenderable | null = null;
@@ -91,12 +101,15 @@ class WorktreeSelector {
91
101
  // Config editor state
92
102
  private isEditingConfig = false;
93
103
  private configContainer: BoxRenderable | null = null;
94
- private configInput: InputRenderable | null = null;
104
+ private configHookInput: InputRenderable | null = null;
105
+ private configOpenInput: InputRenderable | null = null;
106
+ private configActiveField: "hook" | "open" = "hook";
95
107
  private isFirstTimeSetup = false;
96
108
 
97
109
  constructor(
98
110
  private renderer: CliRenderer,
99
111
  private targetPath: string,
112
+ private pkg?: PackageInfo,
100
113
  ) {
101
114
  // Load worktrees first to get initial options
102
115
  this.repoRoot = resolveRepoRoot(this.targetPath);
@@ -113,6 +126,34 @@ class WorktreeSelector {
113
126
  });
114
127
  this.renderer.root.add(this.title);
115
128
 
129
+ // Display version or update notification in title line
130
+ if (this.pkg) {
131
+ const notifier = updateNotifier({ pkg: this.pkg });
132
+
133
+ let noticeContent: string;
134
+ let noticeColor: string;
135
+
136
+ if (notifier.update) {
137
+ // Update available
138
+ noticeContent = `Update: ${notifier.update.current} → ${notifier.update.latest} (npm i -g)`;
139
+ noticeColor = "#F59E0B"; // Amber
140
+ } else {
141
+ // On latest version
142
+ noticeContent = `v${this.pkg.version}`;
143
+ noticeColor = "#64748B"; // Subtle gray
144
+ }
145
+
146
+ this.versionNotice = new TextRenderable(renderer, {
147
+ id: "version-notice",
148
+ position: "absolute",
149
+ left: 78 - noticeContent.length,
150
+ top: 1,
151
+ content: noticeContent,
152
+ fg: noticeColor,
153
+ });
154
+ this.renderer.root.add(this.versionNotice);
155
+ }
156
+
116
157
  this.selectElement = new SelectRenderable(renderer, {
117
158
  id: "worktree-selector",
118
159
  position: "absolute",
@@ -263,8 +304,21 @@ class WorktreeSelector {
263
304
  return;
264
305
  }
265
306
  if (key.name === "return") {
266
- const value = this.configInput?.value || "";
267
- this.handleConfigSave(value);
307
+ this.handleConfigSave();
308
+ return;
309
+ }
310
+ if (key.name === "tab") {
311
+ // Switch between fields
312
+ if (this.configActiveField === "hook") {
313
+ this.configActiveField = "open";
314
+ this.configHookInput?.blur();
315
+ this.configOpenInput?.focus();
316
+ } else {
317
+ this.configActiveField = "hook";
318
+ this.configOpenInput?.blur();
319
+ this.configHookInput?.focus();
320
+ }
321
+ this.renderer.requestRender();
268
322
  return;
269
323
  }
270
324
  return;
@@ -350,9 +404,17 @@ class WorktreeSelector {
350
404
  return;
351
405
  }
352
406
 
353
- const success = openInFileManager(worktree.path);
407
+ // Load config to check for custom open command
408
+ const config = this.repoRoot ? loadRepoConfig(this.repoRoot) : {};
409
+ const customCommand = config.openCommand;
410
+
411
+ const success = openInFileManager(worktree.path, customCommand);
354
412
  if (success) {
355
- this.setStatus(`Opened ${worktree.path} in file manager.`, "success");
413
+ if (customCommand) {
414
+ this.setStatus(`Opened ${worktree.path} with ${customCommand}.`, "success");
415
+ } else {
416
+ this.setStatus(`Opened ${worktree.path} in file manager.`, "success");
417
+ }
356
418
  } else {
357
419
  this.setStatus("Failed to open file manager.", "error");
358
420
  }
@@ -658,6 +720,7 @@ class WorktreeSelector {
658
720
  }
659
721
 
660
722
  this.isEditingConfig = true;
723
+ this.configActiveField = "hook";
661
724
  this.selectElement.visible = false;
662
725
  this.selectElement.blur();
663
726
 
@@ -665,8 +728,8 @@ class WorktreeSelector {
665
728
  const existingConfig = loadRepoConfig(this.repoRoot);
666
729
 
667
730
  const title = this.isFirstTimeSetup
668
- ? "First-time Setup: Configure Post-create Hook"
669
- : "Edit Post-create Hook";
731
+ ? "First-time Setup: Project Configuration"
732
+ : "Edit Project Configuration";
670
733
 
671
734
  this.configContainer = new BoxRenderable(this.renderer, {
672
735
  id: "config-container",
@@ -674,7 +737,7 @@ class WorktreeSelector {
674
737
  left: 2,
675
738
  top: 3,
676
739
  width: 76,
677
- height: 8,
740
+ height: 12,
678
741
  borderStyle: "single",
679
742
  borderColor: "#38BDF8",
680
743
  title,
@@ -684,50 +747,76 @@ class WorktreeSelector {
684
747
  });
685
748
  this.renderer.root.add(this.configContainer);
686
749
 
687
- const helpText = new TextRenderable(this.renderer, {
688
- id: "config-help",
750
+ // Post-create hook field
751
+ const hookLabel = new TextRenderable(this.renderer, {
752
+ id: "config-hook-label",
689
753
  position: "absolute",
690
754
  left: 1,
691
755
  top: 1,
692
- content: "Command to run after creating a worktree (e.g., npm install):",
756
+ content: "Post-create hook (e.g., npm install):",
693
757
  fg: "#94A3B8",
694
758
  });
695
- this.configContainer.add(helpText);
759
+ this.configContainer.add(hookLabel);
760
+
761
+ this.configHookInput = new InputRenderable(this.renderer, {
762
+ id: "config-hook-input",
763
+ position: "absolute",
764
+ left: 1,
765
+ top: 2,
766
+ width: 72,
767
+ placeholder: "npm install",
768
+ value: existingConfig.postCreateHook || "",
769
+ focusedBackgroundColor: "#1E293B",
770
+ backgroundColor: "#1E293B",
771
+ });
772
+ this.configContainer.add(this.configHookInput);
696
773
 
697
- const skipHint = new TextRenderable(this.renderer, {
698
- id: "config-skip-hint",
774
+ // Open command field
775
+ const openLabel = new TextRenderable(this.renderer, {
776
+ id: "config-open-label",
699
777
  position: "absolute",
700
778
  left: 1,
701
779
  top: 4,
702
- content: "Leave empty to skip post-create hooks.",
703
- fg: "#64748B",
780
+ content: "Open folder command (e.g., webstorm, code):",
781
+ fg: "#94A3B8",
704
782
  });
705
- this.configContainer.add(skipHint);
783
+ this.configContainer.add(openLabel);
706
784
 
707
- this.configInput = new InputRenderable(this.renderer, {
708
- id: "config-hook-input",
785
+ this.configOpenInput = new InputRenderable(this.renderer, {
786
+ id: "config-open-input",
709
787
  position: "absolute",
710
788
  left: 1,
711
- top: 3,
789
+ top: 5,
712
790
  width: 72,
713
- placeholder: "npm install",
714
- value: existingConfig.postCreateHook || "",
791
+ placeholder: "open (default)",
792
+ value: existingConfig.openCommand || "",
715
793
  focusedBackgroundColor: "#1E293B",
716
794
  backgroundColor: "#1E293B",
717
795
  });
718
- this.configContainer.add(this.configInput);
796
+ this.configContainer.add(this.configOpenInput);
797
+
798
+ // Help text
799
+ const helpText = new TextRenderable(this.renderer, {
800
+ id: "config-help",
801
+ position: "absolute",
802
+ left: 1,
803
+ top: 7,
804
+ content: "Tab to switch fields • Leave empty to use defaults",
805
+ fg: "#64748B",
806
+ });
807
+ this.configContainer.add(helpText);
719
808
 
720
- this.instructions.content = "Enter to save • Esc to cancel";
809
+ this.instructions.content = "Tab switch • Enter save • Esc cancel";
721
810
  this.setStatus(
722
811
  this.isFirstTimeSetup
723
- ? "Welcome! Configure your post-create hook for this repository."
724
- : "Edit the post-create hook command.",
812
+ ? "Welcome! Configure your project settings."
813
+ : "Edit project configuration.",
725
814
  "info"
726
815
  );
727
816
 
728
817
  // Delay focus to prevent the triggering keypress from being captured
729
818
  setTimeout(() => {
730
- this.configInput?.focus();
819
+ this.configHookInput?.focus();
731
820
  this.renderer.requestRender();
732
821
  }, 0);
733
822
  }
@@ -736,14 +825,18 @@ class WorktreeSelector {
736
825
  this.isEditingConfig = false;
737
826
  this.isFirstTimeSetup = false;
738
827
 
739
- if (this.configInput) {
740
- this.configInput.blur();
828
+ if (this.configHookInput) {
829
+ this.configHookInput.blur();
830
+ }
831
+ if (this.configOpenInput) {
832
+ this.configOpenInput.blur();
741
833
  }
742
834
 
743
835
  if (this.configContainer) {
744
836
  this.renderer.root.remove(this.configContainer.id);
745
837
  this.configContainer = null;
746
- this.configInput = null;
838
+ this.configHookInput = null;
839
+ this.configOpenInput = null;
747
840
  }
748
841
 
749
842
  this.selectElement.visible = true;
@@ -757,27 +850,35 @@ class WorktreeSelector {
757
850
  }, 0);
758
851
  }
759
852
 
760
- private handleConfigSave(hookCommand: string): void {
853
+ private handleConfigSave(): void {
761
854
  if (!this.repoRoot) {
762
855
  this.setStatus("No git repository found.", "error");
763
856
  this.hideConfigEditor();
764
857
  return;
765
858
  }
766
859
 
767
- const trimmed = hookCommand.trim();
860
+ const hookValue = (this.configHookInput?.value || "").trim();
861
+ const openValue = (this.configOpenInput?.value || "").trim();
768
862
  const config: Config = {};
769
863
 
770
- if (trimmed) {
771
- config.postCreateHook = trimmed;
864
+ if (hookValue) {
865
+ config.postCreateHook = hookValue;
866
+ }
867
+ if (openValue) {
868
+ config.openCommand = openValue;
772
869
  }
773
870
 
774
871
  const success = saveRepoConfig(this.repoRoot, config);
775
872
 
776
873
  if (success) {
777
- if (trimmed) {
778
- this.setStatus(`Post-create hook saved: "${trimmed}"`, "success");
874
+ const changes: string[] = [];
875
+ if (hookValue) changes.push(`hook: "${hookValue}"`);
876
+ if (openValue) changes.push(`open: "${openValue}"`);
877
+
878
+ if (changes.length > 0) {
879
+ this.setStatus(`Config saved: ${changes.join(", ")}`, "success");
779
880
  } else {
780
- this.setStatus("Post-create hook cleared.", "success");
881
+ this.setStatus("Config cleared.", "success");
781
882
  }
782
883
  } else {
783
884
  this.setStatus("Failed to save config.", "error");