opencode-worktree 0.3.3 → 0.3.5

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
@@ -1,6 +1,6 @@
1
1
  # opencode-worktree
2
2
 
3
- Terminal UI for managing git worktrees and launching `opencode` in the selected worktree.
3
+ Terminal UI for managing git worktrees and launching your preferred coding tool in the selected worktree.
4
4
 
5
5
  ## Features
6
6
 
@@ -9,17 +9,18 @@ Terminal UI for managing git worktrees and launching `opencode` in the selected
9
9
  - Status indicators: `[main]` for main worktree, `[*]` for uncommitted changes, `[local]` for local-only branches
10
10
  - Create new worktrees directly from the TUI
11
11
  - Post-create hooks: automatically run commands (e.g., `npm install`) after creating a worktree
12
- - Open worktree folder in file manager
12
+ - Open worktree folder in file manager or custom editor
13
13
  - Unlink worktrees (remove directory, keep branch)
14
14
  - Delete worktrees and local branches (never remote)
15
15
  - Multi-select delete mode for batch deletion
16
- - Launches `opencode` in the selected worktree
16
+ - **Customizable launch command**: use `opencode`, `cursor`, `claude`, `code`, or any CLI tool
17
17
  - Refresh list on demand
18
+ - Update notifications when new versions are available
18
19
 
19
20
  ## Requirements
20
21
 
21
22
  - Git
22
- - `opencode` available on your PATH
23
+ - A CLI tool available on your PATH (e.g., `opencode`, `cursor`, `claude`, `code`)
23
24
 
24
25
  ## Install (npm)
25
26
 
@@ -42,11 +43,11 @@ opencode-worktree /path/to/your/repo
42
43
  ## Keybindings
43
44
 
44
45
  - `Up`/`Down` or `j`/`k`: navigate
45
- - `Enter`: open selected worktree in opencode (or toggle selection in delete mode)
46
+ - `Enter`: open selected worktree in configured tool (or toggle selection in delete mode)
46
47
  - `o`: open worktree folder in file manager or custom editor (configurable)
47
48
  - `d`: enter multi-select delete mode (press again to confirm deletion)
48
49
  - `n`: create new worktree
49
- - `c`: edit configuration (post-create hooks)
50
+ - `c`: edit configuration (hooks, open command, launch command)
50
51
  - `r`: refresh list
51
52
  - `q` or `Esc`: quit (or cancel dialogs/modes)
52
53
 
@@ -59,18 +60,33 @@ opencode-worktree /path/to/your/repo
59
60
 
60
61
  ## Configuration
61
62
 
62
- You can configure per-repository settings by creating a `.opencode-worktree.json` file in your repository root.
63
+ You can configure per-repository settings by creating a `.opencode-worktree.json` file in your repository root, or by pressing `c` in the TUI.
64
+
65
+ ### Configuration options
66
+
67
+ | Option | Description | Default |
68
+ |--------|-------------|---------|
69
+ | `postCreateHook` | Command to run after creating a worktree | none |
70
+ | `openCommand` | Command for opening worktree folders (`o` key) | system default |
71
+ | `launchCommand` | Command to launch when selecting a worktree (`Enter` key) | `opencode` |
72
+
73
+ ### Example configuration
74
+
75
+ ```json
76
+ {
77
+ "postCreateHook": "npm install",
78
+ "openCommand": "code",
79
+ "launchCommand": "cursor"
80
+ }
81
+ ```
63
82
 
64
83
  ### First-time setup
65
84
 
66
- When you first run `opencode-worktree` in a repository without a configuration file, you'll be prompted to configure a post-create hook. You can also skip this step and configure it later by pressing `c`.
85
+ When you first run `opencode-worktree` in a repository without a configuration file, you'll be prompted to configure settings. You can also skip this step and configure later by pressing `c`.
67
86
 
68
87
  ### Editing configuration
69
88
 
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`)
89
+ Press `c` at any time to edit your configuration. Use `Tab` to switch between fields.
74
90
 
75
91
  ### Post-create hooks
76
92
 
@@ -82,7 +98,7 @@ Run a command automatically after creating a new worktree. Useful for installing
82
98
  }
83
99
  ```
84
100
 
85
- The hook output is streamed to the TUI in real-time. If the hook fails, you can choose to open opencode anyway or cancel.
101
+ The hook output is streamed to the TUI in real-time. If the hook fails, you can choose to open the tool anyway or cancel.
86
102
 
87
103
  **Examples:**
88
104
 
@@ -116,16 +132,49 @@ Use a custom command when pressing `o` to open worktree folders. Useful for open
116
132
  }
117
133
  ```
118
134
 
135
+ ### Custom launch command
136
+
137
+ Use a different tool instead of `opencode` when pressing `Enter` to open a worktree. This allows you to use any CLI-based coding tool.
138
+
139
+ ```json
140
+ {
141
+ "launchCommand": "cursor"
142
+ }
143
+ ```
144
+
145
+ **Examples:**
146
+
147
+ ```json
148
+ {
149
+ "launchCommand": "claude"
150
+ }
151
+ ```
152
+
153
+ ```json
154
+ {
155
+ "launchCommand": "code"
156
+ }
157
+ ```
158
+
159
+ ```json
160
+ {
161
+ "launchCommand": "zed"
162
+ }
163
+ ```
164
+
165
+ ### Full configuration example
166
+
119
167
  ```json
120
168
  {
121
169
  "postCreateHook": "npm install",
122
- "openCommand": "webstorm"
170
+ "openCommand": "code",
171
+ "launchCommand": "cursor"
123
172
  }
124
173
  ```
125
174
 
126
175
  ## Update notifications
127
176
 
128
- When a new version is published to npm, the CLI will show a non-intrusive update message on the next run.
177
+ When a new version is published to npm, the TUI will show a non-intrusive update message in the title bar. The version check runs in the background and doesn't slow down startup.
129
178
 
130
179
  ## Development
131
180
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-worktree",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "TUI for managing git worktrees with opencode integration.",
@@ -42,13 +42,11 @@
42
42
  "node": ">=18"
43
43
  },
44
44
  "dependencies": {
45
- "@opentui/core": "^0.1.75",
46
- "update-notifier": "^7.3.1"
45
+ "@opentui/core": "^0.1.75"
47
46
  },
48
47
  "devDependencies": {
49
48
  "@types/bun": "^1.2.0",
50
49
  "@types/node": "^24.2.0",
51
- "@types/update-notifier": "^6.0.8",
52
50
  "typescript": "^5.9.3"
53
51
  }
54
52
  }
package/src/config.ts CHANGED
@@ -4,6 +4,7 @@ import { join } from "node:path";
4
4
  export type Config = {
5
5
  postCreateHook?: string;
6
6
  openCommand?: string; // Custom command to open worktree folder (e.g., "webstorm", "code")
7
+ launchCommand?: string; // Custom command to launch instead of opencode (e.g., "cursor", "claude")
7
8
  };
8
9
 
9
10
  const CONFIG_FILENAME = ".opencode-worktree.json";
@@ -51,6 +52,10 @@ export const loadRepoConfig = (repoRoot: string): Config => {
51
52
  config.openCommand = parsed.openCommand;
52
53
  }
53
54
 
55
+ if (typeof parsed.launchCommand === "string") {
56
+ config.launchCommand = parsed.launchCommand;
57
+ }
58
+
54
59
  return config;
55
60
  } catch {
56
61
  // If we can't read or parse the config, return empty
package/src/opencode.ts CHANGED
@@ -1,14 +1,30 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
2
 
3
- export const isOpenCodeAvailable = (): boolean => {
4
- const result = spawnSync("opencode", ["--version"], {
3
+ /**
4
+ * Check if a command is available in PATH
5
+ */
6
+ export const isCommandAvailable = (command: string): boolean => {
7
+ const result = spawnSync(command, ["--version"], {
5
8
  stdio: "ignore",
6
9
  });
7
10
  return result.status === 0;
8
11
  };
9
12
 
10
- export const launchOpenCode = (cwd: string): void => {
11
- const child = spawn("opencode", [], {
13
+ /**
14
+ * @deprecated Use isCommandAvailable instead
15
+ */
16
+ export const isOpenCodeAvailable = (): boolean => {
17
+ return isCommandAvailable("opencode");
18
+ };
19
+
20
+ /**
21
+ * Launch a command in a worktree directory
22
+ * If customCommand is provided, uses that instead of opencode
23
+ */
24
+ export const launchCommand = (cwd: string, customCommand?: string): void => {
25
+ const command = customCommand || "opencode";
26
+
27
+ const child = spawn(command, [], {
12
28
  cwd,
13
29
  stdio: "inherit",
14
30
  });
@@ -19,6 +35,13 @@ export const launchOpenCode = (cwd: string): void => {
19
35
  });
20
36
  };
21
37
 
38
+ /**
39
+ * @deprecated Use launchCommand instead
40
+ */
41
+ export const launchOpenCode = (cwd: string): void => {
42
+ launchCommand(cwd);
43
+ };
44
+
22
45
  /**
23
46
  * Open a path in the system file manager or with a custom command
24
47
  * If customCommand is provided, uses that instead of the system default
package/src/ui.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  type KeyEvent,
11
11
  type SelectOption,
12
12
  } from "@opentui/core";
13
- import updateNotifier from "update-notifier";
13
+ import { checkForUpdate } from "./update-check.js";
14
14
  import { basename } from "node:path";
15
15
  import {
16
16
  createWorktree,
@@ -22,7 +22,7 @@ import {
22
22
  resolveRepoRoot,
23
23
  unlinkWorktree,
24
24
  } from "./git.js";
25
- import { isOpenCodeAvailable, launchOpenCode, openInFileManager } from "./opencode.js";
25
+ import { isCommandAvailable, launchCommand, openInFileManager } from "./opencode.js";
26
26
  import { WorktreeInfo } from "./types.js";
27
27
  import { loadRepoConfig, saveRepoConfig, configExists, type Config } from "./config.js";
28
28
  import { runPostCreateHook, type HookResult } from "./hooks.js";
@@ -81,6 +81,7 @@ class WorktreeSelector {
81
81
 
82
82
  private opencodeAvailable = false;
83
83
  private repoRoot: string | null = null;
84
+ private repoConfig: Config = {};
84
85
  private isCreatingWorktree = false;
85
86
  private worktreeOptions: SelectOption[] = [];
86
87
 
@@ -103,7 +104,8 @@ class WorktreeSelector {
103
104
  private configContainer: BoxRenderable | null = null;
104
105
  private configHookInput: InputRenderable | null = null;
105
106
  private configOpenInput: InputRenderable | null = null;
106
- private configActiveField: "hook" | "open" = "hook";
107
+ private configLaunchInput: InputRenderable | null = null;
108
+ private configActiveField: "hook" | "open" | "launch" = "hook";
107
109
  private isFirstTimeSetup = false;
108
110
 
109
111
  constructor(
@@ -113,7 +115,8 @@ class WorktreeSelector {
113
115
  ) {
114
116
  // Load worktrees first to get initial options
115
117
  this.repoRoot = resolveRepoRoot(this.targetPath);
116
- this.opencodeAvailable = isOpenCodeAvailable();
118
+ this.repoConfig = this.repoRoot ? loadRepoConfig(this.repoRoot) : {};
119
+ this.opencodeAvailable = isCommandAvailable(this.repoConfig.launchCommand || "opencode");
117
120
  this.worktreeOptions = this.buildInitialOptions();
118
121
 
119
122
  this.title = new TextRenderable(renderer, {
@@ -128,17 +131,17 @@ class WorktreeSelector {
128
131
 
129
132
  // Display version or update notification in title line
130
133
  if (this.pkg) {
131
- const notifier = updateNotifier({ pkg: this.pkg });
134
+ const updateInfo = checkForUpdate(this.pkg);
132
135
 
133
136
  let noticeContent: string;
134
137
  let noticeColor: string;
135
138
 
136
- if (notifier.update) {
139
+ if (updateInfo?.hasUpdate) {
137
140
  // Update available
138
- noticeContent = `Update: ${notifier.update.current} → ${notifier.update.latest} (npm i -g)`;
141
+ noticeContent = `Update: ${updateInfo.current} → ${updateInfo.latest} (npm i -g)`;
139
142
  noticeColor = "#F59E0B"; // Amber
140
143
  } else {
141
- // On latest version
144
+ // On latest version (or no cache yet)
142
145
  noticeContent = `v${this.pkg.version}`;
143
146
  noticeColor = "#64748B"; // Subtle gray
144
147
  }
@@ -308,14 +311,18 @@ class WorktreeSelector {
308
311
  return;
309
312
  }
310
313
  if (key.name === "tab") {
311
- // Switch between fields
314
+ // Cycle between fields: hook -> open -> launch -> hook
312
315
  if (this.configActiveField === "hook") {
313
316
  this.configActiveField = "open";
314
317
  this.configHookInput?.blur();
315
318
  this.configOpenInput?.focus();
319
+ } else if (this.configActiveField === "open") {
320
+ this.configActiveField = "launch";
321
+ this.configOpenInput?.blur();
322
+ this.configLaunchInput?.focus();
316
323
  } else {
317
324
  this.configActiveField = "hook";
318
- this.configOpenInput?.blur();
325
+ this.configLaunchInput?.blur();
319
326
  this.configHookInput?.focus();
320
327
  }
321
328
  this.renderer.requestRender();
@@ -388,13 +395,14 @@ class WorktreeSelector {
388
395
  }
389
396
 
390
397
  const worktree = value as WorktreeInfo;
398
+ const cmdName = this.repoConfig.launchCommand || "opencode";
391
399
  if (!this.opencodeAvailable) {
392
- this.setStatus("opencode is not available on PATH.", "error");
400
+ this.setStatus(`${cmdName} is not available on PATH.`, "error");
393
401
  return;
394
402
  }
395
403
 
396
404
  this.cleanup(false);
397
- launchOpenCode(worktree.path);
405
+ launchCommand(worktree.path, this.repoConfig.launchCommand);
398
406
  }
399
407
 
400
408
  private openWorktreeInFileManager(): void {
@@ -521,10 +529,10 @@ class WorktreeSelector {
521
529
  this.pendingWorktreePath = result.path;
522
530
  this.runHook(result.path, config.postCreateHook);
523
531
  } else {
524
- // No hook, launch opencode directly
532
+ // No hook, launch command directly
525
533
  this.hideCreateWorktreeInput();
526
534
  this.cleanup(false);
527
- launchOpenCode(result.path);
535
+ launchCommand(result.path, this.repoConfig.launchCommand);
528
536
  }
529
537
  } else {
530
538
  this.setStatus(`Failed to create worktree: ${result.error}`, "error");
@@ -610,12 +618,12 @@ class WorktreeSelector {
610
618
  this.setStatus("Hook completed successfully!", "success");
611
619
  this.renderer.requestRender();
612
620
 
613
- // Brief delay to show success, then launch opencode
621
+ // Brief delay to show success, then launch command
614
622
  setTimeout(() => {
615
623
  this.hideHookOutput();
616
624
  if (this.pendingWorktreePath) {
617
625
  this.cleanup(false);
618
- launchOpenCode(this.pendingWorktreePath);
626
+ launchCommand(this.pendingWorktreePath, this.repoConfig.launchCommand);
619
627
  }
620
628
  }, 1000);
621
629
  }
@@ -676,7 +684,7 @@ class WorktreeSelector {
676
684
  if (choice === "open" && this.pendingWorktreePath) {
677
685
  this.hideHookOutput();
678
686
  this.cleanup(false);
679
- launchOpenCode(this.pendingWorktreePath);
687
+ launchCommand(this.pendingWorktreePath, this.repoConfig.launchCommand);
680
688
  } else {
681
689
  // Cancel - return to list
682
690
  this.hideHookOutput();
@@ -737,7 +745,7 @@ class WorktreeSelector {
737
745
  left: 2,
738
746
  top: 3,
739
747
  width: 76,
740
- height: 12,
748
+ height: 15,
741
749
  borderStyle: "single",
742
750
  borderColor: "#38BDF8",
743
751
  title,
@@ -771,13 +779,13 @@ class WorktreeSelector {
771
779
  });
772
780
  this.configContainer.add(this.configHookInput);
773
781
 
774
- // Open command field
782
+ // Open folder command field
775
783
  const openLabel = new TextRenderable(this.renderer, {
776
784
  id: "config-open-label",
777
785
  position: "absolute",
778
786
  left: 1,
779
787
  top: 4,
780
- content: "Open folder command (e.g., webstorm, code):",
788
+ content: "Open folder command (e.g., code, webstorm):",
781
789
  fg: "#94A3B8",
782
790
  });
783
791
  this.configContainer.add(openLabel);
@@ -795,12 +803,36 @@ class WorktreeSelector {
795
803
  });
796
804
  this.configContainer.add(this.configOpenInput);
797
805
 
806
+ // Launch command field (instead of opencode)
807
+ const launchLabel = new TextRenderable(this.renderer, {
808
+ id: "config-launch-label",
809
+ position: "absolute",
810
+ left: 1,
811
+ top: 7,
812
+ content: "Launch command (e.g., cursor, claude, code):",
813
+ fg: "#94A3B8",
814
+ });
815
+ this.configContainer.add(launchLabel);
816
+
817
+ this.configLaunchInput = new InputRenderable(this.renderer, {
818
+ id: "config-launch-input",
819
+ position: "absolute",
820
+ left: 1,
821
+ top: 8,
822
+ width: 72,
823
+ placeholder: "opencode (default)",
824
+ value: existingConfig.launchCommand || "",
825
+ focusedBackgroundColor: "#1E293B",
826
+ backgroundColor: "#1E293B",
827
+ });
828
+ this.configContainer.add(this.configLaunchInput);
829
+
798
830
  // Help text
799
831
  const helpText = new TextRenderable(this.renderer, {
800
832
  id: "config-help",
801
833
  position: "absolute",
802
834
  left: 1,
803
- top: 7,
835
+ top: 10,
804
836
  content: "Tab to switch fields • Leave empty to use defaults",
805
837
  fg: "#64748B",
806
838
  });
@@ -831,12 +863,16 @@ class WorktreeSelector {
831
863
  if (this.configOpenInput) {
832
864
  this.configOpenInput.blur();
833
865
  }
866
+ if (this.configLaunchInput) {
867
+ this.configLaunchInput.blur();
868
+ }
834
869
 
835
870
  if (this.configContainer) {
836
871
  this.renderer.root.remove(this.configContainer.id);
837
872
  this.configContainer = null;
838
873
  this.configHookInput = null;
839
874
  this.configOpenInput = null;
875
+ this.configLaunchInput = null;
840
876
  }
841
877
 
842
878
  this.selectElement.visible = true;
@@ -859,6 +895,7 @@ class WorktreeSelector {
859
895
 
860
896
  const hookValue = (this.configHookInput?.value || "").trim();
861
897
  const openValue = (this.configOpenInput?.value || "").trim();
898
+ const launchValue = (this.configLaunchInput?.value || "").trim();
862
899
  const config: Config = {};
863
900
 
864
901
  if (hookValue) {
@@ -867,13 +904,24 @@ class WorktreeSelector {
867
904
  if (openValue) {
868
905
  config.openCommand = openValue;
869
906
  }
907
+ if (launchValue) {
908
+ config.launchCommand = launchValue;
909
+ }
870
910
 
871
911
  const success = saveRepoConfig(this.repoRoot, config);
872
912
 
873
913
  if (success) {
914
+ // Update the in-memory config
915
+ this.repoConfig = config;
916
+
917
+ // Re-check if the launch command is available
918
+ const cmdName = config.launchCommand || "opencode";
919
+ this.opencodeAvailable = isCommandAvailable(cmdName);
920
+
874
921
  const changes: string[] = [];
875
922
  if (hookValue) changes.push(`hook: "${hookValue}"`);
876
923
  if (openValue) changes.push(`open: "${openValue}"`);
924
+ if (launchValue) changes.push(`launch: "${launchValue}"`);
877
925
 
878
926
  if (changes.length > 0) {
879
927
  this.setStatus(`Config saved: ${changes.join(", ")}`, "success");
@@ -922,9 +970,10 @@ class WorktreeSelector {
922
970
  );
923
971
  }
924
972
 
925
- this.opencodeAvailable = isOpenCodeAvailable();
973
+ const cmdName = this.repoConfig.launchCommand || "opencode";
974
+ this.opencodeAvailable = isCommandAvailable(cmdName);
926
975
  if (!this.opencodeAvailable) {
927
- this.setStatus("opencode is not available on PATH.", "error");
976
+ this.setStatus(`${cmdName} is not available on PATH.`, "error");
928
977
  }
929
978
 
930
979
  this.renderer.requestRender();
@@ -1063,7 +1112,7 @@ class WorktreeSelector {
1063
1112
  const branchDisplay = worktree.branch || basename(worktree.path);
1064
1113
 
1065
1114
  // Build dialog title
1066
- const title = "DELETE WORKTREE";
1115
+ const title = `Remove: ${branchDisplay}`;
1067
1116
 
1068
1117
  this.confirmContainer = new BoxRenderable(this.renderer, {
1069
1118
  id: "confirm-container",
@@ -1071,30 +1120,18 @@ class WorktreeSelector {
1071
1120
  left: 2,
1072
1121
  top: 3,
1073
1122
  width: 76,
1074
- height: isDirty ? 12 : 10,
1123
+ height: isDirty ? 10 : 8,
1075
1124
  borderStyle: "single",
1076
- borderColor: "#EF4444",
1125
+ borderColor: "#F59E0B",
1077
1126
  title,
1078
1127
  titleAlignment: "center",
1079
- backgroundColor: "#1C1917",
1128
+ backgroundColor: "#0F172A",
1080
1129
  border: true,
1081
1130
  });
1082
1131
  this.renderer.root.add(this.confirmContainer);
1083
1132
 
1084
- // Branch name prominently displayed
1085
- let yOffset = 1;
1086
- const branchHeader = new TextRenderable(this.renderer, {
1087
- id: "confirm-branch",
1088
- position: "absolute",
1089
- left: 1,
1090
- top: yOffset,
1091
- content: `Branch: ${branchDisplay}`,
1092
- fg: "#FBBF24",
1093
- });
1094
- this.confirmContainer.add(branchHeader);
1095
- yOffset += 2;
1096
-
1097
1133
  // Warning for dirty worktree
1134
+ let yOffset = 1;
1098
1135
  if (isDirty) {
1099
1136
  const warningText = new TextRenderable(this.renderer, {
1100
1137
  id: "confirm-warning",
@@ -1102,7 +1139,7 @@ class WorktreeSelector {
1102
1139
  left: 1,
1103
1140
  top: yOffset,
1104
1141
  content: "⚠ This worktree has uncommitted changes!",
1105
- fg: "#EF4444",
1142
+ fg: "#F59E0B",
1106
1143
  });
1107
1144
  this.confirmContainer.add(warningText);
1108
1145
  yOffset += 2;
@@ -1114,7 +1151,7 @@ class WorktreeSelector {
1114
1151
  left: 1,
1115
1152
  top: yOffset,
1116
1153
  content: `Path: ${worktree.path}`,
1117
- fg: "#A8A29E",
1154
+ fg: "#94A3B8",
1118
1155
  });
1119
1156
  this.confirmContainer.add(pathText);
1120
1157
  yOffset += 2;
@@ -1146,13 +1183,13 @@ class WorktreeSelector {
1146
1183
  width: 72,
1147
1184
  height: 4,
1148
1185
  options,
1149
- backgroundColor: "#1C1917",
1150
- focusedBackgroundColor: "#292524",
1151
- selectedBackgroundColor: "#44403C",
1152
- textColor: "#E7E5E4",
1153
- selectedTextColor: "#F87171",
1154
- descriptionColor: "#A8A29E",
1155
- selectedDescriptionColor: "#E7E5E4",
1186
+ backgroundColor: "#0F172A",
1187
+ focusedBackgroundColor: "#1E293B",
1188
+ selectedBackgroundColor: "#1E3A5F",
1189
+ textColor: "#E2E8F0",
1190
+ selectedTextColor: "#38BDF8",
1191
+ descriptionColor: "#94A3B8",
1192
+ selectedDescriptionColor: "#E2E8F0",
1156
1193
  showDescription: true,
1157
1194
  wrapSelection: true,
1158
1195
  });
@@ -1192,6 +1229,17 @@ class WorktreeSelector {
1192
1229
  this.confirmSelect = null;
1193
1230
  }
1194
1231
 
1232
+ // Restore original title and colors (in case we came from delete mode)
1233
+ this.title.content = "OPENCODE WORKTREES";
1234
+ this.title.fg = "#E2E8F0";
1235
+ this.selectElement.backgroundColor = "#0F172A";
1236
+ this.selectElement.focusedBackgroundColor = "#1E293B";
1237
+ this.selectElement.selectedBackgroundColor = "#1E3A5F";
1238
+ this.selectElement.textColor = "#E2E8F0";
1239
+ this.selectElement.selectedTextColor = "#38BDF8";
1240
+ this.selectElement.descriptionColor = "#94A3B8";
1241
+ this.selectElement.selectedDescriptionColor = "#E2E8F0";
1242
+
1195
1243
  this.selectElement.visible = true;
1196
1244
  this.instructions.content =
1197
1245
  "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
@@ -1283,8 +1331,21 @@ class WorktreeSelector {
1283
1331
  this.isSelectingForDelete = true;
1284
1332
  this.selectedForDelete.clear();
1285
1333
 
1286
- // Rebuild options to show checkboxes
1287
- this.selectElement.options = this.buildOptions(worktrees);
1334
+ // Change title to indicate delete mode
1335
+ this.title.content = "DELETE WORKTREES";
1336
+ this.title.fg = "#EF4444"; // Red
1337
+
1338
+ // Change select element colors to danger theme
1339
+ this.selectElement.backgroundColor = "#1C1917";
1340
+ this.selectElement.focusedBackgroundColor = "#292524";
1341
+ this.selectElement.selectedBackgroundColor = "#44403C";
1342
+ this.selectElement.textColor = "#E7E5E4";
1343
+ this.selectElement.selectedTextColor = "#F87171";
1344
+ this.selectElement.descriptionColor = "#A8A29E";
1345
+ this.selectElement.selectedDescriptionColor = "#E7E5E4";
1346
+
1347
+ // Rebuild options to show checkboxes (only deletable worktrees)
1348
+ this.selectElement.options = this.buildOptions(deletableWorktrees);
1288
1349
  this.instructions.content =
1289
1350
  "Enter toggle selection • d confirm delete • Esc cancel";
1290
1351
  this.setStatus("Select worktrees to delete, then press 'd' to confirm.", "info");
@@ -1294,6 +1355,20 @@ class WorktreeSelector {
1294
1355
  private exitSelectMode(): void {
1295
1356
  this.isSelectingForDelete = false;
1296
1357
  this.selectedForDelete.clear();
1358
+
1359
+ // Restore original title
1360
+ this.title.content = "OPENCODE WORKTREES";
1361
+ this.title.fg = "#E2E8F0";
1362
+
1363
+ // Restore original select element colors
1364
+ this.selectElement.backgroundColor = "#0F172A";
1365
+ this.selectElement.focusedBackgroundColor = "#1E293B";
1366
+ this.selectElement.selectedBackgroundColor = "#1E3A5F";
1367
+ this.selectElement.textColor = "#E2E8F0";
1368
+ this.selectElement.selectedTextColor = "#38BDF8";
1369
+ this.selectElement.descriptionColor = "#94A3B8";
1370
+ this.selectElement.selectedDescriptionColor = "#E2E8F0";
1371
+
1297
1372
  this.loadWorktrees();
1298
1373
  this.instructions.content =
1299
1374
  "↑/↓ navigate • Enter open • o folder • d delete • n new • c config • q quit";
@@ -1320,10 +1395,13 @@ class WorktreeSelector {
1320
1395
  this.selectedForDelete.add(worktree.path);
1321
1396
  }
1322
1397
 
1323
- // Rebuild options to update checkboxes
1398
+ // Rebuild options to update checkboxes (only deletable worktrees)
1324
1399
  if (this.repoRoot) {
1325
1400
  const worktrees = listWorktrees(this.repoRoot);
1326
- this.selectElement.options = this.buildOptions(worktrees);
1401
+ const deletableWorktrees = worktrees.filter(
1402
+ (wt) => !isMainWorktree(this.repoRoot!, wt.path)
1403
+ );
1404
+ this.selectElement.options = this.buildOptions(deletableWorktrees);
1327
1405
  // Restore selection index
1328
1406
  this.selectElement.setSelectedIndex(selectedIndex);
1329
1407
  }
@@ -1369,7 +1447,7 @@ class WorktreeSelector {
1369
1447
  const hasDirty = dirtyWorktrees.length > 0;
1370
1448
 
1371
1449
  const count = worktrees.length;
1372
- const title = `DELETE ${count} WORKTREE${count === 1 ? "" : "S"}`;
1450
+ const title = `Delete ${count} worktree${count === 1 ? "" : "s"}`;
1373
1451
 
1374
1452
  this.confirmContainer = new BoxRenderable(this.renderer, {
1375
1453
  id: "confirm-container",
@@ -1377,36 +1455,18 @@ class WorktreeSelector {
1377
1455
  left: 2,
1378
1456
  top: 3,
1379
1457
  width: 76,
1380
- height: hasDirty ? 14 : 12,
1458
+ height: hasDirty ? 12 : 10,
1381
1459
  borderStyle: "single",
1382
- borderColor: "#EF4444",
1460
+ borderColor: "#F59E0B",
1383
1461
  title,
1384
1462
  titleAlignment: "center",
1385
- backgroundColor: "#1C1917",
1463
+ backgroundColor: "#0F172A",
1386
1464
  border: true,
1387
1465
  });
1388
1466
  this.renderer.root.add(this.confirmContainer);
1389
1467
 
1390
1468
  let yOffset = 1;
1391
1469
 
1392
- // List branches to be deleted prominently
1393
- const branchNames = worktrees
1394
- .map((wt) => wt.branch || basename(wt.path))
1395
- .slice(0, 3);
1396
- const displayList =
1397
- branchNames.join(", ") + (worktrees.length > 3 ? `, +${worktrees.length - 3} more` : "");
1398
-
1399
- const branchHeader = new TextRenderable(this.renderer, {
1400
- id: "confirm-branches",
1401
- position: "absolute",
1402
- left: 1,
1403
- top: yOffset,
1404
- content: `Branches: ${displayList}`,
1405
- fg: "#FBBF24",
1406
- });
1407
- this.confirmContainer.add(branchHeader);
1408
- yOffset += 2;
1409
-
1410
1470
  // Warning for dirty worktrees
1411
1471
  if (hasDirty) {
1412
1472
  const warningText = new TextRenderable(this.renderer, {
@@ -1415,22 +1475,28 @@ class WorktreeSelector {
1415
1475
  left: 1,
1416
1476
  top: yOffset,
1417
1477
  content: `⚠ ${dirtyWorktrees.length} worktree${dirtyWorktrees.length === 1 ? " has" : "s have"} uncommitted changes!`,
1418
- fg: "#EF4444",
1478
+ fg: "#F59E0B",
1419
1479
  });
1420
1480
  this.confirmContainer.add(warningText);
1421
1481
  yOffset += 2;
1422
1482
  }
1423
1483
 
1424
- // Info text
1425
- const infoText = new TextRenderable(this.renderer, {
1426
- id: "confirm-info",
1484
+ // List worktrees to be deleted
1485
+ const branchNames = worktrees
1486
+ .map((wt) => wt.branch || basename(wt.path))
1487
+ .slice(0, 3);
1488
+ const displayList =
1489
+ branchNames.join(", ") + (worktrees.length > 3 ? `, +${worktrees.length - 3} more` : "");
1490
+
1491
+ const listText = new TextRenderable(this.renderer, {
1492
+ id: "confirm-list",
1427
1493
  position: "absolute",
1428
1494
  left: 1,
1429
1495
  top: yOffset,
1430
- content: "This will remove worktree directories from disk.",
1431
- fg: "#A8A29E",
1496
+ content: `Worktrees: ${displayList}`,
1497
+ fg: "#94A3B8",
1432
1498
  });
1433
- this.confirmContainer.add(infoText);
1499
+ this.confirmContainer.add(listText);
1434
1500
  yOffset += 2;
1435
1501
 
1436
1502
  // Build options
@@ -1460,13 +1526,13 @@ class WorktreeSelector {
1460
1526
  width: 72,
1461
1527
  height: 4,
1462
1528
  options,
1463
- backgroundColor: "#1C1917",
1464
- focusedBackgroundColor: "#292524",
1465
- selectedBackgroundColor: "#44403C",
1466
- textColor: "#E7E5E4",
1467
- selectedTextColor: "#F87171",
1468
- descriptionColor: "#A8A29E",
1469
- selectedDescriptionColor: "#E7E5E4",
1529
+ backgroundColor: "#0F172A",
1530
+ focusedBackgroundColor: "#1E293B",
1531
+ selectedBackgroundColor: "#1E3A5F",
1532
+ textColor: "#E2E8F0",
1533
+ selectedTextColor: "#38BDF8",
1534
+ descriptionColor: "#94A3B8",
1535
+ selectedDescriptionColor: "#E2E8F0",
1470
1536
  showDescription: true,
1471
1537
  wrapSelection: true,
1472
1538
  });
@@ -0,0 +1,163 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+
6
+ export type UpdateInfo = {
7
+ hasUpdate: boolean;
8
+ current: string;
9
+ latest: string;
10
+ };
11
+
12
+ type UpdateCache = {
13
+ name: string; // Package name to verify cache matches
14
+ latest: string;
15
+ lastCheck: number;
16
+ };
17
+
18
+ const CACHE_DIR = path.join(os.homedir(), ".config", "configstore");
19
+ const CACHE_FILE = path.join(CACHE_DIR, "opencode-worktree-update.json");
20
+
21
+ // Check interval: 1 hour in milliseconds
22
+ const CHECK_INTERVAL_MS = 60 * 60 * 1000;
23
+
24
+ /**
25
+ * Read the cached update info from disk
26
+ * Returns null if cache doesn't exist or package name doesn't match
27
+ */
28
+ function readCache(packageName: string): UpdateCache | null {
29
+ try {
30
+ if (!fs.existsSync(CACHE_FILE)) {
31
+ return null;
32
+ }
33
+ const content = fs.readFileSync(CACHE_FILE, "utf-8");
34
+ const cache = JSON.parse(content) as UpdateCache;
35
+
36
+ // Verify the cache is for the correct package
37
+ if (cache.name !== packageName) {
38
+ return null;
39
+ }
40
+
41
+ return cache;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Compare two semver version strings
49
+ * Returns true if latest > current
50
+ */
51
+ function isNewerVersion(current: string, latest: string): boolean {
52
+ // Handle dev versions
53
+ if (current === "dev" || current === "0.0.0") {
54
+ return false;
55
+ }
56
+
57
+ const parseVersion = (v: string): number[] => {
58
+ return v
59
+ .replace(/^v/, "")
60
+ .split(".")
61
+ .map((n) => parseInt(n, 10) || 0);
62
+ };
63
+
64
+ const currentParts = parseVersion(current);
65
+ const latestParts = parseVersion(latest);
66
+
67
+ for (let i = 0; i < 3; i++) {
68
+ const c = currentParts[i] || 0;
69
+ const l = latestParts[i] || 0;
70
+ if (l > c) return true;
71
+ if (l < c) return false;
72
+ }
73
+
74
+ return false;
75
+ }
76
+
77
+ /**
78
+ * Spawn a detached background process to fetch the latest version from npm
79
+ * and write it to the cache file. This doesn't block the main process.
80
+ */
81
+ function fetchLatestInBackground(packageName: string): void {
82
+ // Create an inline script that fetches from npm and writes to cache
83
+ const script = `
84
+ const https = require('https');
85
+ const fs = require('fs');
86
+ const path = require('path');
87
+ const os = require('os');
88
+
89
+ const cacheDir = path.join(os.homedir(), '.config', 'configstore');
90
+ const cacheFile = path.join(cacheDir, 'opencode-worktree-update.json');
91
+
92
+ const url = 'https://registry.npmjs.org/${packageName}/latest';
93
+
94
+ https.get(url, { headers: { 'Accept': 'application/json' } }, (res) => {
95
+ let data = '';
96
+ res.on('data', (chunk) => { data += chunk; });
97
+ res.on('end', () => {
98
+ try {
99
+ const pkg = JSON.parse(data);
100
+ if (pkg.version) {
101
+ if (!fs.existsSync(cacheDir)) {
102
+ fs.mkdirSync(cacheDir, { recursive: true });
103
+ }
104
+ fs.writeFileSync(cacheFile, JSON.stringify({
105
+ name: '${packageName}',
106
+ latest: pkg.version,
107
+ lastCheck: Date.now()
108
+ }, null, 2));
109
+ }
110
+ } catch {}
111
+ });
112
+ }).on('error', () => {});
113
+ `;
114
+
115
+ try {
116
+ // Spawn node to run the script
117
+ const child = spawn(process.execPath, ["-e", script], {
118
+ detached: true,
119
+ stdio: "ignore",
120
+ env: { ...process.env },
121
+ });
122
+
123
+ // Unref so the parent process can exit independently
124
+ child.unref();
125
+ } catch {
126
+ // Silently fail - background fetch is best-effort
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Check for updates. This is non-blocking:
132
+ * 1. Reads the cached latest version (if available)
133
+ * 2. Compares against current version
134
+ * 3. Spawns a background process to refresh the cache for next time
135
+ *
136
+ * Returns null if no cache exists yet (first run)
137
+ */
138
+ export function checkForUpdate(pkg: {
139
+ name: string;
140
+ version: string;
141
+ }): UpdateInfo | null {
142
+ const cache = readCache(pkg.name);
143
+ const now = Date.now();
144
+
145
+ // Spawn background fetch if cache is stale or doesn't exist
146
+ const shouldFetch =
147
+ !cache || now - cache.lastCheck > CHECK_INTERVAL_MS;
148
+
149
+ if (shouldFetch) {
150
+ fetchLatestInBackground(pkg.name);
151
+ }
152
+
153
+ // If no cache, we can't determine if there's an update yet
154
+ if (!cache) {
155
+ return null;
156
+ }
157
+
158
+ return {
159
+ hasUpdate: isNewerVersion(pkg.version, cache.latest),
160
+ current: pkg.version,
161
+ latest: cache.latest,
162
+ };
163
+ }