@ohzw/worktree-command-tui 0.1.1 → 0.1.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.
Files changed (39) hide show
  1. package/README.md +5 -0
  2. package/dist/app.d.ts +1 -1
  3. package/dist/app.js +235 -113
  4. package/dist/components/ActionPanel.d.ts +4 -2
  5. package/dist/components/ActionPanel.js +87 -135
  6. package/dist/components/ContextBar.d.ts +4 -1
  7. package/dist/components/ContextBar.js +27 -3
  8. package/dist/components/HelpWindow.d.ts +7 -0
  9. package/dist/components/HelpWindow.js +29 -0
  10. package/dist/components/LogPanel.d.ts +10 -3
  11. package/dist/components/LogPanel.js +239 -33
  12. package/dist/components/WorktreeList.js +20 -40
  13. package/dist/core/command-runner.d.ts +11 -0
  14. package/dist/core/command-runner.js +44 -0
  15. package/dist/core/config-lifecycle.d.ts +25 -0
  16. package/dist/core/config-lifecycle.js +143 -0
  17. package/dist/core/config.d.ts +2 -3
  18. package/dist/core/config.js +0 -48
  19. package/dist/core/git-metadata.d.ts +25 -0
  20. package/dist/core/git-metadata.js +84 -0
  21. package/dist/core/git-worktrees.d.ts +2 -1
  22. package/dist/core/git-worktrees.js +30 -11
  23. package/dist/core/github-metadata.d.ts +14 -0
  24. package/dist/core/github-metadata.js +137 -0
  25. package/dist/core/init.d.ts +3 -2
  26. package/dist/core/init.js +9 -57
  27. package/dist/core/log-reader.d.ts +7 -0
  28. package/dist/core/log-reader.js +43 -0
  29. package/dist/core/runtime-state.d.ts +42 -0
  30. package/dist/core/runtime-state.js +125 -0
  31. package/dist/core/runtime.d.ts +19 -39
  32. package/dist/core/runtime.js +112 -216
  33. package/dist/core/tui-interaction.d.ts +31 -0
  34. package/dist/core/tui-interaction.js +59 -0
  35. package/dist/core/worktree-projection.d.ts +76 -0
  36. package/dist/core/worktree-projection.js +124 -0
  37. package/dist/terminal/viewport.d.ts +15 -0
  38. package/dist/terminal/viewport.js +49 -0
  39. package/package.json +1 -1
package/README.md CHANGED
@@ -65,6 +65,8 @@ A minimal example of the generated config:
65
65
  "namespace": "worktree-command-tui",
66
66
  // Command executed in each selected worktree
67
67
  "command": ["npm", "run", "start"],
68
+ // Optional setup command run manually in the selected worktree
69
+ "setupCommand": ["npm", "install"],
68
70
  // Port used for cleanup/monitoring
69
71
  "port": 3000,
70
72
  // Required files that must exist in a worktree
@@ -74,6 +76,9 @@ A minimal example of the generated config:
74
76
  }
75
77
  ```
76
78
 
79
+ When `setupCommand` is configured, press `i` in the TUI to run it for the selected worktree.
80
+ It is never run automatically when switching worktrees.
81
+
77
82
  ## Development
78
83
 
79
84
  ```bash
package/dist/app.d.ts CHANGED
@@ -14,7 +14,7 @@ export declare function getMouseWheelDelta(input: string): number;
14
14
  export declare function getShellDimensions(columns: number, rows: number): ShellDimensions;
15
15
  export declare function shouldUseCompactLayout(_columns: number, _rows: number, _worktreeCount?: number): boolean;
16
16
  export declare function shouldUseMinimalLayout(columns: number, rows: number): boolean;
17
- export declare function shouldStackPanes(columns: number, rows: number, worktreeCount?: number): boolean;
17
+ export declare function shouldStackPanes(columns: number, rows: number, _worktreeCount?: number): boolean;
18
18
  export declare function App({ initialModel, actions, windowSizeOverride, }: {
19
19
  initialModel: AppModel;
20
20
  actions: AppActions;
package/dist/app.js CHANGED
@@ -5,9 +5,11 @@ import { Box, Text, useApp, useInput, useStdin, useStdout, useWindowSize } from
5
5
  import { ActionPanel } from './components/ActionPanel.js';
6
6
  import { ContextBar } from './components/ContextBar.js';
7
7
  import { Header } from './components/Header.js';
8
+ import { HelpWindow } from './components/HelpWindow.js';
8
9
  import { FloatingLogWindow } from './components/FloatingLogWindow.js';
9
10
  import { LogPanel, buildLogLines } from './components/LogPanel.js';
10
11
  import { WorktreeList } from './components/WorktreeList.js';
12
+ import { clampSelectionIndex, decideEnterInteraction, decideSetupInteraction, getNextSelectedPath, getSelectedIndex } from './core/tui-interaction.js';
11
13
  const ENABLE_MOUSE_TRACKING = '\u001B[?1000h\u001B[?1006h';
12
14
  const DISABLE_MOUSE_TRACKING = '\u001B[?1000l\u001B[?1006l';
13
15
  function parseMouseWheelEvents(input) {
@@ -31,19 +33,10 @@ function parseMouseWheelEvents(input) {
31
33
  export function getMouseWheelDelta(input) {
32
34
  return parseMouseWheelEvents(input).reduce((sum, event) => sum + event.delta, 0);
33
35
  }
34
- function getNextSelectedPath(rows, currentPath) {
35
- if (rows.length === 0) {
36
- return null;
37
- }
38
- if (currentPath && rows.some(row => row.path === currentPath)) {
39
- return currentPath;
40
- }
41
- return rows[0].path;
42
- }
43
36
  export function getShellDimensions(columns, rows) {
44
37
  const rootWidth = Math.max(columns, 1);
45
38
  const rootHeight = Math.max(rows, 1);
46
- const bodyWidth = Math.max(rootWidth - 4, 1);
39
+ const bodyWidth = rootWidth;
47
40
  const maxListWidth = Math.max(1, bodyWidth - 21);
48
41
  const desiredListWidth = Math.max(1, Math.floor((bodyWidth - 1) * 0.34));
49
42
  const listWidth = Math.min(42, desiredListWidth, maxListWidth);
@@ -56,15 +49,61 @@ export function shouldUseCompactLayout(_columns, _rows, _worktreeCount = 0) {
56
49
  export function shouldUseMinimalLayout(columns, rows) {
57
50
  return columns < 20 || rows < 10;
58
51
  }
59
- export function shouldStackPanes(columns, rows, worktreeCount = 0) {
60
- // Stacked panes are taller than split panes. Only use them when the full frame can fit the viewport.
61
- const minimumRows = Math.max(36, worktreeCount + 34);
62
- return columns < 96 && rows >= minimumRows;
52
+ const STACKED_LAYOUT_FRAME_ROWS = 9;
53
+ const HEADER_HEIGHT = 5;
54
+ const CONTEXT_BAR_HEIGHT = 4;
55
+ const PANE_GAP_WIDTH = 1;
56
+ const MIN_STACKED_PANE_HEIGHT = 9;
57
+ export function shouldStackPanes(columns, rows, _worktreeCount = 0) {
58
+ // Header + context bar consume the fixed chrome; each stacked pane still keeps ~6 visible content lines at the minimum height.
59
+ return columns < 96 && rows >= STACKED_LAYOUT_FRAME_ROWS + (MIN_STACKED_PANE_HEIGHT * 2);
63
60
  }
64
61
  function getLogPaneHeight(_rootHeight) {
65
- // Outer pane height. With border + title, 9 rows gives ~6 visible log lines.
62
+ // Each bordered log pane keeps ~6 visible content lines at 9 rows.
66
63
  return 9;
67
64
  }
65
+ const ACTIVE_TAG = 'active';
66
+ const ALREADY_ACTIVE_MESSAGE = 'already active';
67
+ function syncActiveTags(rows, activePath) {
68
+ let changed = false;
69
+ const nextRows = rows.map(row => {
70
+ const isActive = row.path === activePath;
71
+ const hasActiveTag = row.tags.includes(ACTIVE_TAG);
72
+ if (isActive === hasActiveTag) {
73
+ return row;
74
+ }
75
+ changed = true;
76
+ return {
77
+ ...row,
78
+ tags: isActive ? [...row.tags, ACTIVE_TAG] : row.tags.filter(tag => tag !== ACTIVE_TAG),
79
+ };
80
+ });
81
+ return changed ? nextRows : rows;
82
+ }
83
+ function shouldRefreshLogs(model) {
84
+ if (model.activePath === null) {
85
+ return false;
86
+ }
87
+ if (model.status.kind === 'running' || model.status.kind === 'error') {
88
+ return true;
89
+ }
90
+ return model.status.message === ALREADY_ACTIVE_MESSAGE;
91
+ }
92
+ function getStatusAfterLogRefresh(current, refresh) {
93
+ if (current.status.kind !== 'running') {
94
+ return current.status;
95
+ }
96
+ if (current.activePath === refresh.activePath && current.activeBranch === refresh.activeBranch) {
97
+ return current.status;
98
+ }
99
+ if (refresh.activePath === null) {
100
+ return { kind: 'idle', message: 'session ended' };
101
+ }
102
+ if (refresh.activeBranch) {
103
+ return { kind: 'running', message: `Active: ${refresh.activeBranch}` };
104
+ }
105
+ return { kind: 'running', message: 'running' };
106
+ }
68
107
  export function App({ initialModel, actions, windowSizeOverride, }) {
69
108
  const { exit } = useApp();
70
109
  const { stdin } = useStdin();
@@ -77,11 +116,12 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
77
116
  const [worktreeScrollOffset, setWorktreeScrollOffset] = useState(0);
78
117
  const [logScrollOffset, setLogScrollOffset] = useState(0);
79
118
  const [isLogOverlayOpen, setIsLogOverlayOpen] = useState(false);
119
+ const [isHelpOverlayOpen, setIsHelpOverlayOpen] = useState(false);
120
+ const [pendingDelete, setPendingDelete] = useState(null);
80
121
  const [completedAlert, setCompletedAlert] = useState(null);
81
122
  const userActionInFlightRef = useRef(false);
82
- const backgroundRefreshInFlightRef = useRef(false);
83
- const actionGenerationRef = useRef(0);
84
123
  const logRefreshInFlightRef = useRef(false);
124
+ const actionGenerationRef = useRef(0);
85
125
  const previousStatusRef = useRef(initialModel.status.kind);
86
126
  const alertTimeoutRef = useRef(null);
87
127
  useEffect(() => {
@@ -110,31 +150,40 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
110
150
  }
111
151
  };
112
152
  }, []);
153
+ const confirmationOpen = pendingDelete !== null;
154
+ const visibleStatus = confirmationOpen
155
+ ? { kind: 'idle', message: `Delete ${pendingDelete.branch}? d/y confirm, Esc/n/q cancel` }
156
+ : model.status;
113
157
  useEffect(() => {
114
- return () => {
115
- if (alertTimeoutRef.current !== null) {
116
- clearTimeout(alertTimeoutRef.current);
117
- }
118
- };
119
- }, []);
120
- useEffect(() => {
121
- if (model.status.kind !== 'running') {
158
+ if (!shouldRefreshLogs(model)) {
122
159
  return;
123
160
  }
124
- const fullRefreshInterval = setInterval(() => {
125
- if (userActionInFlightRef.current || backgroundRefreshInFlightRef.current) {
126
- return;
127
- }
128
- void apply(() => actions.refresh(), { blocksInput: false });
129
- }, 1500);
161
+ // Only logs and active-session liveness need near-real-time updates.
162
+ // Full worktree metadata includes GitHub PR lookups and is refreshed by
163
+ // explicit user actions instead of a tight polling loop.
130
164
  const logRefreshInterval = setInterval(() => {
131
- if (userActionInFlightRef.current || backgroundRefreshInFlightRef.current || logRefreshInFlightRef.current) {
165
+ if (userActionInFlightRef.current || logRefreshInFlightRef.current) {
132
166
  return;
133
167
  }
168
+ const generation = actionGenerationRef.current;
134
169
  logRefreshInFlightRef.current = true;
135
170
  void actions.refreshLogs()
136
- .then(logs => {
137
- setModel(current => ({ ...current, logs }));
171
+ .then(refresh => {
172
+ if (generation !== actionGenerationRef.current || userActionInFlightRef.current) {
173
+ return;
174
+ }
175
+ setModel(current => {
176
+ const activeChanged = current.activePath !== refresh.activePath || current.activeBranch !== refresh.activeBranch;
177
+ const status = getStatusAfterLogRefresh(current, refresh);
178
+ return {
179
+ ...current,
180
+ logs: refresh.logs,
181
+ activePath: refresh.activePath,
182
+ activeBranch: refresh.activeBranch,
183
+ status,
184
+ rows: activeChanged ? syncActiveTags(current.rows, refresh.activePath) : current.rows,
185
+ };
186
+ });
138
187
  })
139
188
  .catch(() => { })
140
189
  .finally(() => {
@@ -142,17 +191,15 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
142
191
  });
143
192
  }, 400);
144
193
  return () => {
145
- clearInterval(fullRefreshInterval);
146
194
  clearInterval(logRefreshInterval);
147
195
  };
148
- }, [actions, model.status.kind]);
149
- const selectedIndex = useMemo(() => {
150
- if (selectedPath === null) {
151
- return 0;
152
- }
153
- const foundIndex = model.rows.findIndex(row => row.path === selectedPath);
154
- return foundIndex >= 0 ? foundIndex : 0;
155
- }, [model.rows, selectedPath]);
196
+ }, [actions, model.activePath, model.status.kind, model.status.message]);
197
+ useEffect(() => {
198
+ if (confirmationOpen && !model.rows.some(row => row.path === pendingDelete.path)) {
199
+ setPendingDelete(null);
200
+ }
201
+ }, [confirmationOpen, model.rows, pendingDelete]);
202
+ const selectedIndex = useMemo(() => getSelectedIndex(model.rows, selectedPath), [model.rows, selectedPath]);
156
203
  const selected = model.rows[selectedIndex];
157
204
  const { rootWidth, rootHeight, bodyWidth, listWidth, actionWidth } = getShellDimensions(columns, rows);
158
205
  const minimalLayout = shouldUseMinimalLayout(rootWidth, rootHeight);
@@ -161,10 +208,11 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
161
208
  const compactDetailPane = !stackedLayout && rootHeight <= 30 && model.rows.length > 1;
162
209
  const showLogPanel = !stackedLayout && rootHeight >= 34;
163
210
  const logPaneHeight = showLogPanel ? getLogPaneHeight(rootHeight) : 0;
211
+ const stackedPaneHeight = Math.max(3, Math.floor((rootHeight - STACKED_LAYOUT_FRAME_ROWS) / 2));
164
212
  const paneHeight = stackedLayout
165
- ? undefined
166
- : Math.max(3, rootHeight - 11 - logPaneHeight);
167
- const selectionScrollPageSize = Math.max(1, Math.floor((paneHeight ?? rootHeight) / 2));
213
+ ? stackedPaneHeight
214
+ : Math.max(3, rootHeight - STACKED_LAYOUT_FRAME_ROWS - logPaneHeight);
215
+ const selectionScrollPageSize = Math.max(1, Math.floor(paneHeight / 2));
168
216
  const logLineCount = useMemo(() => buildLogLines(model.logs).length, [model.logs]);
169
217
  const logViewportHeight = isLogOverlayOpen
170
218
  ? Math.max(1, rootHeight - 3)
@@ -172,10 +220,11 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
172
220
  const maxLogScrollOffset = Math.max(0, logLineCount - logViewportHeight);
173
221
  const logScrollPageSize = Math.max(1, Math.floor((logViewportHeight || rootHeight) / 2));
174
222
  function moveSelection(nextIndex) {
175
- if (model.rows.length === 0) {
223
+ const clampedIndex = clampSelectionIndex(nextIndex, model.rows.length);
224
+ if (clampedIndex === null) {
176
225
  return;
177
226
  }
178
- setSelectedPath(model.rows[Math.min(Math.max(nextIndex, 0), model.rows.length - 1)].path);
227
+ setSelectedPath(model.rows[clampedIndex].path);
179
228
  }
180
229
  function clearTransientAlert() {
181
230
  if (alertTimeoutRef.current !== null) {
@@ -184,47 +233,40 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
184
233
  }
185
234
  setCompletedAlert(null);
186
235
  }
187
- function invalidateBackgroundRefreshes() {
236
+ function invalidateStaleLogRefreshes() {
188
237
  actionGenerationRef.current += 1;
189
238
  }
190
- async function apply(action, options = {}) {
191
- const blocksInput = options.blocksInput ?? true;
192
- const generation = blocksInput ? actionGenerationRef.current + 1 : actionGenerationRef.current;
193
- if (blocksInput) {
194
- actionGenerationRef.current = generation;
195
- userActionInFlightRef.current = true;
196
- }
197
- else {
198
- backgroundRefreshInFlightRef.current = true;
199
- }
239
+ async function apply(action) {
240
+ invalidateStaleLogRefreshes();
241
+ userActionInFlightRef.current = true;
200
242
  try {
201
- const next = await action();
202
- if (blocksInput || (generation === actionGenerationRef.current && !userActionInFlightRef.current)) {
203
- setModel(next);
204
- }
243
+ setModel(await action());
205
244
  }
206
245
  catch (error) {
207
- if (blocksInput || (generation === actionGenerationRef.current && !userActionInFlightRef.current)) {
208
- setModel(current => ({
209
- ...current,
210
- status: {
211
- kind: 'error',
212
- message: error instanceof Error ? error.message : String(error),
213
- },
214
- }));
215
- }
246
+ setModel(current => ({
247
+ ...current,
248
+ status: {
249
+ kind: 'error',
250
+ message: error instanceof Error ? error.message : String(error),
251
+ },
252
+ }));
216
253
  }
217
254
  finally {
218
- if (blocksInput) {
219
- userActionInFlightRef.current = false;
220
- }
221
- else {
222
- backgroundRefreshInFlightRef.current = false;
223
- }
255
+ userActionInFlightRef.current = false;
224
256
  }
225
257
  }
226
258
  useInput((input, key) => {
259
+ if (isHelpOverlayOpen) {
260
+ if (key.escape || input === '\u001B' || input === 'q' || input === '?') {
261
+ setIsHelpOverlayOpen(false);
262
+ }
263
+ return;
264
+ }
227
265
  if (isLogOverlayOpen) {
266
+ if (input === '?') {
267
+ setIsHelpOverlayOpen(true);
268
+ return;
269
+ }
228
270
  if (key.escape || input === 'q' || input === 'L') {
229
271
  setIsLogOverlayOpen(false);
230
272
  return;
@@ -255,6 +297,20 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
255
297
  }
256
298
  return;
257
299
  }
300
+ if (confirmationOpen) {
301
+ if (key.escape || input === '\u001B' || input === 'q' || input === 'n') {
302
+ setPendingDelete(null);
303
+ return;
304
+ }
305
+ if (input === 'd' || input === 'y') {
306
+ const { path: worktreePath } = pendingDelete;
307
+ setPendingDelete(null);
308
+ clearTransientAlert();
309
+ void apply(() => actions.deleteWorktree(worktreePath));
310
+ return;
311
+ }
312
+ return;
313
+ }
258
314
  if (key.escape || input === 'q') {
259
315
  exit();
260
316
  return;
@@ -275,6 +331,10 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
275
331
  moveSelection(model.rows.length - 1);
276
332
  return;
277
333
  }
334
+ if (input === '?') {
335
+ setIsHelpOverlayOpen(true);
336
+ return;
337
+ }
278
338
  if (input === 'L') {
279
339
  setIsLogOverlayOpen(true);
280
340
  return;
@@ -298,22 +358,22 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
298
358
  if (userActionInFlightRef.current) {
299
359
  return;
300
360
  }
301
- if (key.return && selected) {
302
- if (selected.invalidReason) {
303
- invalidateBackgroundRefreshes();
304
- setModel(current => ({ ...current, status: { kind: 'error', message: selected.invalidReason } }));
305
- clearTransientAlert();
361
+ if (key.return) {
362
+ const decision = decideEnterInteraction(selected, model.activePath);
363
+ if (decision.kind === 'ignore') {
306
364
  return;
307
365
  }
308
- if (selected.path === model.activePath) {
309
- invalidateBackgroundRefreshes();
310
- setModel(current => ({ ...current, status: { kind: 'idle', message: 'already active' } }));
366
+ if (decision.kind === 'set-status') {
367
+ if (decision.suppressesBackgroundRefreshes) {
368
+ invalidateStaleLogRefreshes();
369
+ }
370
+ setModel(current => ({ ...current, status: decision.status }));
311
371
  clearTransientAlert();
312
372
  return;
313
373
  }
314
- setModel(current => ({ ...current, status: { kind: 'starting', message: `Starting ${selected.branch}...` } }));
374
+ setModel(current => ({ ...current, status: decision.status }));
315
375
  clearTransientAlert();
316
- void apply(() => actions.start(selected.path));
376
+ void apply(() => actions.start(decision.path));
317
377
  return;
318
378
  }
319
379
  if (input === 's') {
@@ -322,6 +382,31 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
322
382
  void apply(() => actions.stop());
323
383
  return;
324
384
  }
385
+ if (input === 'i') {
386
+ const decision = decideSetupInteraction(selected, model.setupAvailable);
387
+ if (decision.kind === 'ignore') {
388
+ return;
389
+ }
390
+ setModel(current => ({ ...current, status: decision.status }));
391
+ clearTransientAlert();
392
+ void apply(() => actions.setup(decision.path));
393
+ return;
394
+ }
395
+ if (input === 'e' && selected && model.editorAvailable) {
396
+ clearTransientAlert();
397
+ void apply(() => actions.openEditor(selected.path));
398
+ return;
399
+ }
400
+ if (input === 'o' && selected) {
401
+ clearTransientAlert();
402
+ void apply(() => actions.openPullRequest(selected.path));
403
+ return;
404
+ }
405
+ if (input === 'd' && selected) {
406
+ clearTransientAlert();
407
+ setPendingDelete({ path: selected.path, branch: selected.branch });
408
+ return;
409
+ }
325
410
  if (input === 'r') {
326
411
  clearTransientAlert();
327
412
  void apply(() => actions.refresh());
@@ -329,15 +414,22 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
329
414
  });
330
415
  const listPaneViewportHeight = paneHeight === undefined ? undefined : Math.max(1, paneHeight - 3);
331
416
  const mouseWheelLineStep = 3;
332
- const paneAreaLeft = 3;
417
+ const paneAreaLeft = 1;
333
418
  const worktreePaneRight = !stackedLayout ? paneAreaLeft + listWidth - 1 : undefined;
334
- const selectionPaneLeft = !stackedLayout && worktreePaneRight !== undefined ? worktreePaneRight + 2 : undefined;
335
- const bodyPaneTop = !stackedLayout && paneHeight !== undefined ? 7 : undefined;
419
+ const selectionPaneLeft = !stackedLayout && worktreePaneRight !== undefined ? worktreePaneRight + PANE_GAP_WIDTH + 1 : undefined;
420
+ const bodyPaneTop = !stackedLayout && paneHeight !== undefined ? HEADER_HEIGHT + 1 : undefined;
336
421
  const bodyPaneBottom = !stackedLayout && bodyPaneTop !== undefined && paneHeight !== undefined ? bodyPaneTop + paneHeight - 1 : undefined;
337
- const logPaneTop = showLogPanel ? rootHeight - 5 - logPaneHeight + 1 : undefined;
338
- const logPaneBottom = showLogPanel ? rootHeight - 5 : undefined;
422
+ const stackedWorktreePaneTop = stackedLayout && paneHeight !== undefined ? HEADER_HEIGHT + 1 : undefined;
423
+ const stackedWorktreePaneBottom = stackedLayout && stackedWorktreePaneTop !== undefined && paneHeight !== undefined ? stackedWorktreePaneTop + paneHeight - 1 : undefined;
424
+ const stackedSelectionPaneTop = stackedLayout && stackedWorktreePaneBottom !== undefined ? stackedWorktreePaneBottom + 1 : undefined;
425
+ const stackedSelectionPaneBottom = stackedLayout && stackedSelectionPaneTop !== undefined && paneHeight !== undefined ? stackedSelectionPaneTop + paneHeight - 1 : undefined;
426
+ const logPaneTop = showLogPanel ? rootHeight - CONTEXT_BAR_HEIGHT - logPaneHeight + 1 : undefined;
427
+ const logPaneBottom = showLogPanel ? rootHeight - CONTEXT_BAR_HEIGHT : undefined;
339
428
  useEffect(() => {
340
429
  const onData = (data) => {
430
+ if (isHelpOverlayOpen) {
431
+ return;
432
+ }
341
433
  const events = parseMouseWheelEvents(typeof data === 'string' ? data : data.toString('utf8'));
342
434
  for (const event of events) {
343
435
  if (isLogOverlayOpen) {
@@ -355,32 +447,55 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
355
447
  setLogScrollOffset(current => Math.max(0, Math.min(maxLogScrollOffset, current - event.delta * mouseWheelLineStep)));
356
448
  continue;
357
449
  }
450
+ const scrollWorktrees = () => {
451
+ setWorktreeScrollOffset(current => {
452
+ if (listPaneViewportHeight === undefined) {
453
+ return 0;
454
+ }
455
+ const max = Math.max(0, model.rows.length - listPaneViewportHeight);
456
+ return Math.max(0, Math.min(max, current + event.delta * mouseWheelLineStep));
457
+ });
458
+ };
459
+ if (stackedLayout) {
460
+ const isStackedWorktreeEvent = stackedWorktreePaneTop !== undefined
461
+ && stackedWorktreePaneBottom !== undefined
462
+ && event.y !== undefined
463
+ && event.y >= stackedWorktreePaneTop
464
+ && event.y <= stackedWorktreePaneBottom;
465
+ if (isStackedWorktreeEvent) {
466
+ scrollWorktrees();
467
+ continue;
468
+ }
469
+ const isStackedSelectionEvent = stackedSelectionPaneTop !== undefined
470
+ && stackedSelectionPaneBottom !== undefined
471
+ && event.y !== undefined
472
+ && event.y >= stackedSelectionPaneTop
473
+ && event.y <= stackedSelectionPaneBottom;
474
+ if (isStackedSelectionEvent) {
475
+ setSelectionScrollOffset(current => Math.max(0, current + event.delta * mouseWheelLineStep));
476
+ }
477
+ continue;
478
+ }
358
479
  const isBodyPaneEvent = bodyPaneTop !== undefined
359
480
  && bodyPaneBottom !== undefined
360
481
  && event.y !== undefined
361
482
  && event.y >= bodyPaneTop
362
483
  && event.y <= bodyPaneBottom;
363
- const isSelectionPaneEvent = isBodyPaneEvent
364
- && selectionPaneLeft !== undefined
484
+ if (!isBodyPaneEvent) {
485
+ continue;
486
+ }
487
+ const isSelectionPaneEvent = selectionPaneLeft !== undefined
365
488
  && event.x !== undefined
366
489
  && event.x >= selectionPaneLeft;
367
490
  if (isSelectionPaneEvent) {
368
491
  setSelectionScrollOffset(current => Math.max(0, current + event.delta * mouseWheelLineStep));
369
492
  continue;
370
493
  }
371
- const shouldScrollWorktrees = !stackedLayout
372
- && (event.x === undefined || worktreePaneRight === undefined || event.x <= worktreePaneRight);
373
- if (shouldScrollWorktrees) {
374
- setWorktreeScrollOffset(current => {
375
- if (listPaneViewportHeight === undefined) {
376
- return 0;
377
- }
378
- const max = Math.max(0, model.rows.length - listPaneViewportHeight);
379
- return Math.max(0, Math.min(max, current + event.delta * mouseWheelLineStep));
380
- });
381
- }
382
- else {
383
- setSelectionScrollOffset(current => Math.max(0, current + event.delta * mouseWheelLineStep));
494
+ const isWorktreePaneEvent = event.x === undefined
495
+ || worktreePaneRight === undefined
496
+ || event.x <= worktreePaneRight;
497
+ if (isWorktreePaneEvent) {
498
+ scrollWorktrees();
384
499
  }
385
500
  }
386
501
  };
@@ -394,7 +509,7 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
394
509
  stdout.write(DISABLE_MOUSE_TRACKING);
395
510
  }
396
511
  };
397
- }, [stdin, stdout, listWidth, stackedLayout, listPaneViewportHeight, mouseWheelLineStep, model.rows.length, showLogPanel, logPaneTop, logPaneBottom, maxLogScrollOffset, worktreePaneRight, selectionPaneLeft, bodyPaneTop, bodyPaneBottom, isLogOverlayOpen]);
512
+ }, [stdin, stdout, listWidth, stackedLayout, listPaneViewportHeight, mouseWheelLineStep, model.rows.length, showLogPanel, logPaneTop, logPaneBottom, maxLogScrollOffset, worktreePaneRight, selectionPaneLeft, bodyPaneTop, bodyPaneBottom, stackedWorktreePaneTop, stackedWorktreePaneBottom, stackedSelectionPaneTop, stackedSelectionPaneBottom, isLogOverlayOpen, isHelpOverlayOpen]);
398
513
  useEffect(() => {
399
514
  if (listPaneViewportHeight === undefined) {
400
515
  setWorktreeScrollOffset(0);
@@ -418,16 +533,23 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
418
533
  }
419
534
  setLogScrollOffset(current => Math.min(current, maxLogScrollOffset));
420
535
  }, [showLogPanel, isLogOverlayOpen, maxLogScrollOffset]);
536
+ if (isHelpOverlayOpen) {
537
+ return (_jsx(HelpWindow, { setupAvailable: model.setupAvailable, editorAvailable: model.editorAvailable, width: Math.max(1, rootWidth - 1), height: rootHeight }));
538
+ }
421
539
  if (isLogOverlayOpen) {
422
540
  return (_jsx(FloatingLogWindow, { logs: model.logs, width: Math.max(1, rootWidth - 1), height: rootHeight, scrollOffset: logScrollOffset }));
423
541
  }
424
542
  if (minimalLayout) {
425
- return (_jsxs(Box, { width: rootWidth, height: rootHeight, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["A:", model.activeBranch ?? '-'] }), rootHeight >= 2 ? _jsxs(Text, { wrap: "truncate-end", children: ["S:", selected?.branch ?? '-'] }) : null, rootHeight >= 3 ? _jsxs(Text, { wrap: "truncate-end", children: ["T:", model.status.kind] }) : null, rootHeight >= 4 ? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "\u2191\u2193jk\u21B5Lq" }) : null] }));
543
+ return (_jsxs(Box, { width: rootWidth, height: rootHeight, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["A:", model.activeBranch ?? '-'] }), rootHeight >= 2 ? _jsxs(Text, { wrap: "truncate-end", children: ["S:", selected?.branch ?? '-'] }) : null, rootHeight >= 3 ? _jsx(Text, { wrap: "truncate-end", children: confirmationOpen ? `D:${visibleStatus.message}` : `T:${model.status.kind}` }) : null, rootHeight >= 4 ? (confirmationOpen
544
+ ? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "d/y confirm \u00B7 Esc/n/q cancel" })
545
+ : _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193jk\u21B5", model.setupAvailable ? 'i' : '', model.editorAvailable ? 'e' : '', "odLq"] })) : null] }));
426
546
  }
427
547
  if (compactLayout) {
428
- return (_jsxs(Box, { width: rootWidth, height: rootHeight, borderStyle: "round", borderColor: "yellow", flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["Active: ", model.activeBranch ?? '-'] }), _jsxs(Text, { wrap: "truncate-end", children: ["Selected: ", selected?.branch ?? '-'] }), completedAlert
548
+ return (_jsxs(Box, { width: rootWidth, height: rootHeight, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", wrap: "truncate-end", children: ["Active: ", model.activeBranch ?? '-'] }), _jsxs(Text, { wrap: "truncate-end", children: ["Selected: ", selected?.branch ?? '-'] }), completedAlert
429
549
  ? _jsxs(Text, { color: "green", wrap: "truncate-end", children: ["\u2714 ", completedAlert] })
430
- : model.status.kind === 'starting' || model.status.kind === 'stopping' ? (_jsx(Spinner, { label: `Status: ${model.status.kind} — ${model.status.message}` })) : (_jsxs(Text, { wrap: "truncate-end", children: ["Status: ", model.status.kind, " \u2014 ", model.status.message] })), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Keys: \u2191\u2193/jk g/G \u21B5 L s r q \u00B7 Resize terminal for split view" })] }));
550
+ : model.status.kind === 'setting-up' || model.status.kind === 'starting' || model.status.kind === 'stopping' ? (_jsx(Spinner, { label: `Status: ${model.status.kind} — ${model.status.message}` })) : (_jsxs(Text, { wrap: "truncate-end", children: ["Status: ", visibleStatus.kind, " \u2014 ", visibleStatus.message] })), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: confirmationOpen
551
+ ? 'Keys: d/y confirm | Esc/n/q cancel'
552
+ : `Keys: ↑↓/jk g/G ↵${model.setupAvailable ? ' i' : ''}${model.editorAvailable ? ' e' : ''} o d L s r q · Resize terminal for split view` })] }));
431
553
  }
432
- return (_jsxs(Box, { width: rootWidth, height: rootHeight, borderStyle: "round", borderColor: "gray", flexDirection: "column", paddingX: 1, children: [_jsx(Header, { repoName: model.repoName, namespace: model.namespace, activeBranch: model.activeBranch }), _jsxs(Box, { flexDirection: stackedLayout ? 'column' : 'row', flexGrow: stackedLayout ? 0 : 1, flexShrink: 1, children: [_jsx(WorktreeList, { rows: model.rows, selectedIndex: selectedIndex, width: stackedLayout ? bodyWidth : listWidth, height: paneHeight, stacked: stackedLayout, scrollOffset: worktreeScrollOffset }), _jsx(ActionPanel, { selectedRow: selected, activePath: model.activePath, stacked: stackedLayout, width: stackedLayout ? bodyWidth : actionWidth, height: paneHeight, compactDetails: compactDetailPane, scrollOffset: selectionScrollOffset })] }), showLogPanel ? _jsx(LogPanel, { logs: model.logs, width: bodyWidth, height: logPaneHeight, scrollOffset: logScrollOffset }) : null, _jsx(ContextBar, { status: model.status }), completedAlert ? (_jsx(Box, { position: "absolute", top: 1, right: 2, children: _jsx(Alert, { variant: "success", children: completedAlert }) })) : null] }));
554
+ return (_jsxs(Box, { width: rootWidth, height: rootHeight, flexDirection: "column", children: [_jsx(Header, { repoName: model.repoName, namespace: model.namespace, activeBranch: model.activeBranch }), _jsxs(Box, { flexDirection: stackedLayout ? 'column' : 'row', flexGrow: stackedLayout ? 0 : 1, flexShrink: 1, children: [_jsx(WorktreeList, { rows: model.rows, selectedIndex: selectedIndex, width: stackedLayout ? bodyWidth : listWidth, height: paneHeight, stacked: stackedLayout, scrollOffset: worktreeScrollOffset }), _jsx(ActionPanel, { selectedRow: selected, activePath: model.activePath, setupAvailable: model.setupAvailable, stacked: stackedLayout, width: stackedLayout ? bodyWidth : actionWidth, height: paneHeight, compactDetails: compactDetailPane, scrollOffset: selectionScrollOffset })] }), showLogPanel ? _jsx(LogPanel, { logs: model.logs, width: bodyWidth, height: logPaneHeight, scrollOffset: logScrollOffset }) : null, _jsx(ContextBar, { status: visibleStatus, setupAvailable: model.setupAvailable, editorAvailable: model.editorAvailable, confirmationOpen: confirmationOpen }), completedAlert ? (_jsx(Box, { position: "absolute", top: 1, right: 2, children: _jsx(Alert, { variant: "success", children: completedAlert }) })) : null] }));
433
555
  }
@@ -1,9 +1,11 @@
1
1
  import type { AppRow } from '../core/runtime.js';
2
- export declare function getActionVariant(selectedRow: AppRow, activePath: string | null): 'success' | 'error' | 'info';
2
+ import { type ProjectionSeverity } from '../core/worktree-projection.js';
3
+ export declare function getActionVariant(selectedRow: AppRow, activePath: string | null): ProjectionSeverity;
3
4
  export declare function getPullRequestColor(selectedRow: AppRow): 'green' | 'yellow' | 'red' | undefined;
4
- export declare function ActionPanel({ selectedRow, activePath, stacked, width, height, compactDetails, scrollOffset, }: {
5
+ export declare function ActionPanel({ selectedRow, activePath, setupAvailable, stacked, width, height, compactDetails, scrollOffset, }: {
5
6
  selectedRow: AppRow | undefined;
6
7
  activePath: string | null;
8
+ setupAvailable: boolean;
7
9
  stacked: boolean;
8
10
  width?: number;
9
11
  height?: number;