@ohzw/worktree-command-tui 0.1.0 → 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 (49) hide show
  1. package/README.md +5 -0
  2. package/dist/app.d.ts +3 -2
  3. package/dist/app.js +458 -42
  4. package/dist/components/ActionPanel.d.ts +6 -2
  5. package/dist/components/ActionPanel.js +141 -82
  6. package/dist/components/ContextBar.d.ts +4 -1
  7. package/dist/components/ContextBar.js +37 -4
  8. package/dist/components/FloatingLogWindow.d.ts +7 -0
  9. package/dist/components/FloatingLogWindow.js +5 -0
  10. package/dist/components/HelpWindow.d.ts +7 -0
  11. package/dist/components/HelpWindow.js +29 -0
  12. package/dist/components/LogPanel.d.ts +22 -0
  13. package/dist/components/LogPanel.js +260 -0
  14. package/dist/components/WorktreeList.d.ts +3 -1
  15. package/dist/components/WorktreeList.js +25 -30
  16. package/dist/core/command-runner.d.ts +11 -0
  17. package/dist/core/command-runner.js +44 -0
  18. package/dist/core/config-lifecycle.d.ts +25 -0
  19. package/dist/core/config-lifecycle.js +143 -0
  20. package/dist/core/config.d.ts +2 -3
  21. package/dist/core/config.js +0 -48
  22. package/dist/core/git-metadata.d.ts +25 -0
  23. package/dist/core/git-metadata.js +84 -0
  24. package/dist/core/git-worktrees.d.ts +2 -1
  25. package/dist/core/git-worktrees.js +30 -11
  26. package/dist/core/github-metadata.d.ts +14 -0
  27. package/dist/core/github-metadata.js +137 -0
  28. package/dist/core/init.d.ts +3 -2
  29. package/dist/core/init.js +9 -57
  30. package/dist/core/log-reader.d.ts +7 -0
  31. package/dist/core/log-reader.js +43 -0
  32. package/dist/core/runtime-state.d.ts +42 -0
  33. package/dist/core/runtime-state.js +125 -0
  34. package/dist/core/runtime.d.ts +20 -33
  35. package/dist/core/runtime.js +116 -173
  36. package/dist/core/tui-interaction.d.ts +31 -0
  37. package/dist/core/tui-interaction.js +59 -0
  38. package/dist/core/worktree-projection.d.ts +76 -0
  39. package/dist/core/worktree-projection.js +124 -0
  40. package/dist/main.js +24 -2
  41. package/dist/render-options.d.ts +1 -0
  42. package/dist/render-options.js +1 -0
  43. package/dist/repro.d.ts +1 -0
  44. package/dist/repro.js +13 -0
  45. package/dist/terminal/viewport.d.ts +15 -0
  46. package/dist/terminal/viewport.js +49 -0
  47. package/dist/ui-theme.d.ts +3 -0
  48. package/dist/ui-theme.js +38 -0
  49. package/package.json +2 -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
@@ -10,10 +10,11 @@ export interface AppWindowSize {
10
10
  columns: number;
11
11
  rows: number;
12
12
  }
13
+ export declare function getMouseWheelDelta(input: string): number;
13
14
  export declare function getShellDimensions(columns: number, rows: number): ShellDimensions;
14
- export declare function shouldUseCompactLayout(columns: number, rows: number, worktreeCount?: number): boolean;
15
+ export declare function shouldUseCompactLayout(_columns: number, _rows: number, _worktreeCount?: number): boolean;
15
16
  export declare function shouldUseMinimalLayout(columns: number, rows: number): boolean;
16
- export declare function shouldStackPanes(columns: number, rows: number, worktreeCount?: number): boolean;
17
+ export declare function shouldStackPanes(columns: number, rows: number, _worktreeCount?: number): boolean;
17
18
  export declare function App({ initialModel, actions, windowSizeOverride, }: {
18
19
  initialModel: AppModel;
19
20
  actions: AppActions;
package/dist/app.js CHANGED
@@ -1,74 +1,246 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Alert, Spinner } from '@inkjs/ui';
2
3
  import { useEffect, useMemo, useRef, useState } from 'react';
3
- import { Box, Text, useApp, useInput, useWindowSize } from 'ink';
4
+ import { Box, Text, useApp, useInput, useStdin, useStdout, useWindowSize } from 'ink';
4
5
  import { ActionPanel } from './components/ActionPanel.js';
5
6
  import { ContextBar } from './components/ContextBar.js';
6
7
  import { Header } from './components/Header.js';
8
+ import { HelpWindow } from './components/HelpWindow.js';
9
+ import { FloatingLogWindow } from './components/FloatingLogWindow.js';
10
+ import { LogPanel, buildLogLines } from './components/LogPanel.js';
7
11
  import { WorktreeList } from './components/WorktreeList.js';
8
- function getNextSelectedPath(rows, currentPath) {
9
- if (rows.length === 0) {
10
- return null;
11
- }
12
- if (currentPath && rows.some(row => row.path === currentPath)) {
13
- return currentPath;
12
+ import { clampSelectionIndex, decideEnterInteraction, decideSetupInteraction, getNextSelectedPath, getSelectedIndex } from './core/tui-interaction.js';
13
+ const ENABLE_MOUSE_TRACKING = '\u001B[?1000h\u001B[?1006h';
14
+ const DISABLE_MOUSE_TRACKING = '\u001B[?1000l\u001B[?1006l';
15
+ function parseMouseWheelEvents(input) {
16
+ const events = [];
17
+ const sgrMousePattern = /\u001B\[<(\d+);(\d+);(\d+)[mM]/g;
18
+ for (const match of input.matchAll(sgrMousePattern)) {
19
+ const button = Number(match[1]);
20
+ if (button !== 64 && button !== 65) {
21
+ continue;
22
+ }
23
+ const x = Number(match[2]);
24
+ const y = Number(match[3]);
25
+ events.push({
26
+ delta: button === 65 ? 1 : -1,
27
+ x: Number.isFinite(x) ? x : undefined,
28
+ y: Number.isFinite(y) ? y : undefined,
29
+ });
14
30
  }
15
- return rows[0].path;
31
+ return events;
32
+ }
33
+ export function getMouseWheelDelta(input) {
34
+ return parseMouseWheelEvents(input).reduce((sum, event) => sum + event.delta, 0);
16
35
  }
17
36
  export function getShellDimensions(columns, rows) {
18
37
  const rootWidth = Math.max(columns, 1);
19
38
  const rootHeight = Math.max(rows, 1);
20
- const bodyWidth = Math.max(rootWidth - 4, 1);
39
+ const bodyWidth = rootWidth;
21
40
  const maxListWidth = Math.max(1, bodyWidth - 21);
22
41
  const desiredListWidth = Math.max(1, Math.floor((bodyWidth - 1) * 0.34));
23
42
  const listWidth = Math.min(42, desiredListWidth, maxListWidth);
24
43
  const actionWidth = Math.max(1, bodyWidth - listWidth - 1);
25
44
  return { rootWidth, rootHeight, bodyWidth, listWidth, actionWidth };
26
45
  }
27
- export function shouldUseCompactLayout(columns, rows, worktreeCount = 0) {
28
- const contentAwareRowFloor = Math.max(20, worktreeCount + 12);
29
- return columns < 72 || rows <= contentAwareRowFloor || (columns < 96 && rows < 24);
46
+ export function shouldUseCompactLayout(_columns, _rows, _worktreeCount = 0) {
47
+ return false;
30
48
  }
31
49
  export function shouldUseMinimalLayout(columns, rows) {
32
- return columns < 20 || rows < 6;
50
+ return columns < 20 || rows < 10;
51
+ }
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);
60
+ }
61
+ function getLogPaneHeight(_rootHeight) {
62
+ // Each bordered log pane keeps ~6 visible content lines at 9 rows.
63
+ return 9;
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;
33
91
  }
34
- export function shouldStackPanes(columns, rows, worktreeCount = 0) {
35
- const minimumRows = Math.max(26, worktreeCount + 18);
36
- return columns < 96 && rows >= minimumRows;
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' };
37
106
  }
38
107
  export function App({ initialModel, actions, windowSizeOverride, }) {
39
108
  const { exit } = useApp();
109
+ const { stdin } = useStdin();
110
+ const { stdout } = useStdout();
40
111
  const liveWindowSize = useWindowSize();
41
112
  const { columns, rows } = windowSizeOverride ?? liveWindowSize;
42
113
  const [model, setModel] = useState(initialModel);
43
114
  const [selectedPath, setSelectedPath] = useState(initialModel.rows[0]?.path ?? null);
44
- const inFlightRef = useRef(false);
115
+ const [selectionScrollOffset, setSelectionScrollOffset] = useState(0);
116
+ const [worktreeScrollOffset, setWorktreeScrollOffset] = useState(0);
117
+ const [logScrollOffset, setLogScrollOffset] = useState(0);
118
+ const [isLogOverlayOpen, setIsLogOverlayOpen] = useState(false);
119
+ const [isHelpOverlayOpen, setIsHelpOverlayOpen] = useState(false);
120
+ const [pendingDelete, setPendingDelete] = useState(null);
121
+ const [completedAlert, setCompletedAlert] = useState(null);
122
+ const userActionInFlightRef = useRef(false);
123
+ const logRefreshInFlightRef = useRef(false);
124
+ const actionGenerationRef = useRef(0);
125
+ const previousStatusRef = useRef(initialModel.status.kind);
126
+ const alertTimeoutRef = useRef(null);
45
127
  useEffect(() => {
46
128
  setSelectedPath(currentPath => getNextSelectedPath(model.rows, currentPath));
47
129
  }, [model.rows]);
48
- const selectedIndex = useMemo(() => {
49
- if (selectedPath === null) {
50
- return 0;
130
+ useEffect(() => {
131
+ setSelectionScrollOffset(0);
132
+ }, [selectedPath]);
133
+ useEffect(() => {
134
+ const becameRunning = previousStatusRef.current === 'starting' && model.status.kind === 'running';
135
+ if (becameRunning) {
136
+ setCompletedAlert(model.activeBranch ? `Switched to ${model.activeBranch}` : 'Worktree switch complete.');
137
+ if (alertTimeoutRef.current !== null) {
138
+ clearTimeout(alertTimeoutRef.current);
139
+ }
140
+ alertTimeoutRef.current = setTimeout(() => {
141
+ setCompletedAlert(null);
142
+ }, 2500);
143
+ }
144
+ previousStatusRef.current = model.status.kind;
145
+ }, [model.status.kind, model.activeBranch]);
146
+ useEffect(() => {
147
+ return () => {
148
+ if (alertTimeoutRef.current !== null) {
149
+ clearTimeout(alertTimeoutRef.current);
150
+ }
151
+ };
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;
157
+ useEffect(() => {
158
+ if (!shouldRefreshLogs(model)) {
159
+ return;
160
+ }
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.
164
+ const logRefreshInterval = setInterval(() => {
165
+ if (userActionInFlightRef.current || logRefreshInFlightRef.current) {
166
+ return;
167
+ }
168
+ const generation = actionGenerationRef.current;
169
+ logRefreshInFlightRef.current = true;
170
+ void actions.refreshLogs()
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
+ });
187
+ })
188
+ .catch(() => { })
189
+ .finally(() => {
190
+ logRefreshInFlightRef.current = false;
191
+ });
192
+ }, 400);
193
+ return () => {
194
+ clearInterval(logRefreshInterval);
195
+ };
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);
51
200
  }
52
- const foundIndex = model.rows.findIndex(row => row.path === selectedPath);
53
- return foundIndex >= 0 ? foundIndex : 0;
54
- }, [model.rows, selectedPath]);
201
+ }, [confirmationOpen, model.rows, pendingDelete]);
202
+ const selectedIndex = useMemo(() => getSelectedIndex(model.rows, selectedPath), [model.rows, selectedPath]);
55
203
  const selected = model.rows[selectedIndex];
56
204
  const { rootWidth, rootHeight, bodyWidth, listWidth, actionWidth } = getShellDimensions(columns, rows);
57
205
  const minimalLayout = shouldUseMinimalLayout(rootWidth, rootHeight);
58
206
  const compactLayout = !minimalLayout && shouldUseCompactLayout(rootWidth, rootHeight, model.rows.length);
59
207
  const stackedLayout = !minimalLayout && !compactLayout && shouldStackPanes(rootWidth, rootHeight, model.rows.length);
60
- const compactDetailPane = !stackedLayout && rootHeight <= 26;
208
+ const compactDetailPane = !stackedLayout && rootHeight <= 30 && model.rows.length > 1;
209
+ const showLogPanel = !stackedLayout && rootHeight >= 34;
210
+ const logPaneHeight = showLogPanel ? getLogPaneHeight(rootHeight) : 0;
211
+ const stackedPaneHeight = Math.max(3, Math.floor((rootHeight - STACKED_LAYOUT_FRAME_ROWS) / 2));
212
+ const paneHeight = stackedLayout
213
+ ? stackedPaneHeight
214
+ : Math.max(3, rootHeight - STACKED_LAYOUT_FRAME_ROWS - logPaneHeight);
215
+ const selectionScrollPageSize = Math.max(1, Math.floor(paneHeight / 2));
216
+ const logLineCount = useMemo(() => buildLogLines(model.logs).length, [model.logs]);
217
+ const logViewportHeight = isLogOverlayOpen
218
+ ? Math.max(1, rootHeight - 3)
219
+ : showLogPanel ? Math.max(1, logPaneHeight - 3) : 0;
220
+ const maxLogScrollOffset = Math.max(0, logLineCount - logViewportHeight);
221
+ const logScrollPageSize = Math.max(1, Math.floor((logViewportHeight || rootHeight) / 2));
61
222
  function moveSelection(nextIndex) {
62
- if (model.rows.length === 0) {
223
+ const clampedIndex = clampSelectionIndex(nextIndex, model.rows.length);
224
+ if (clampedIndex === null) {
63
225
  return;
64
226
  }
65
- setSelectedPath(model.rows[Math.min(Math.max(nextIndex, 0), model.rows.length - 1)].path);
227
+ setSelectedPath(model.rows[clampedIndex].path);
228
+ }
229
+ function clearTransientAlert() {
230
+ if (alertTimeoutRef.current !== null) {
231
+ clearTimeout(alertTimeoutRef.current);
232
+ alertTimeoutRef.current = null;
233
+ }
234
+ setCompletedAlert(null);
235
+ }
236
+ function invalidateStaleLogRefreshes() {
237
+ actionGenerationRef.current += 1;
66
238
  }
67
239
  async function apply(action) {
68
- inFlightRef.current = true;
240
+ invalidateStaleLogRefreshes();
241
+ userActionInFlightRef.current = true;
69
242
  try {
70
- const next = await action();
71
- setModel(next);
243
+ setModel(await action());
72
244
  }
73
245
  catch (error) {
74
246
  setModel(current => ({
@@ -80,10 +252,65 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
80
252
  }));
81
253
  }
82
254
  finally {
83
- inFlightRef.current = false;
255
+ userActionInFlightRef.current = false;
84
256
  }
85
257
  }
86
258
  useInput((input, key) => {
259
+ if (isHelpOverlayOpen) {
260
+ if (key.escape || input === '\u001B' || input === 'q' || input === '?') {
261
+ setIsHelpOverlayOpen(false);
262
+ }
263
+ return;
264
+ }
265
+ if (isLogOverlayOpen) {
266
+ if (input === '?') {
267
+ setIsHelpOverlayOpen(true);
268
+ return;
269
+ }
270
+ if (key.escape || input === 'q' || input === 'L') {
271
+ setIsLogOverlayOpen(false);
272
+ return;
273
+ }
274
+ if (key.upArrow || input === 'k') {
275
+ setLogScrollOffset(current => Math.min(maxLogScrollOffset, current + 1));
276
+ return;
277
+ }
278
+ if (key.downArrow || input === 'j') {
279
+ setLogScrollOffset(current => Math.max(0, current - 1));
280
+ return;
281
+ }
282
+ if (input === 'g') {
283
+ setLogScrollOffset(maxLogScrollOffset);
284
+ return;
285
+ }
286
+ if (input === 'G') {
287
+ setLogScrollOffset(0);
288
+ return;
289
+ }
290
+ if (input === '[' || key.pageUp) {
291
+ setLogScrollOffset(current => Math.min(maxLogScrollOffset, current + logScrollPageSize));
292
+ return;
293
+ }
294
+ if (input === ']' || key.pageDown) {
295
+ setLogScrollOffset(current => Math.max(0, current - logScrollPageSize));
296
+ return;
297
+ }
298
+ return;
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
+ }
87
314
  if (key.escape || input === 'q') {
88
315
  exit();
89
316
  return;
@@ -104,36 +331,225 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
104
331
  moveSelection(model.rows.length - 1);
105
332
  return;
106
333
  }
107
- if (inFlightRef.current) {
334
+ if (input === '?') {
335
+ setIsHelpOverlayOpen(true);
336
+ return;
337
+ }
338
+ if (input === 'L') {
339
+ setIsLogOverlayOpen(true);
340
+ return;
341
+ }
342
+ if (input === ']') {
343
+ setLogScrollOffset(current => Math.max(0, current - logScrollPageSize));
344
+ return;
345
+ }
346
+ if (input === '[') {
347
+ setLogScrollOffset(current => Math.min(maxLogScrollOffset, current + logScrollPageSize));
348
+ return;
349
+ }
350
+ if (key.pageDown) {
351
+ setSelectionScrollOffset(current => current + selectionScrollPageSize);
352
+ return;
353
+ }
354
+ if (key.pageUp) {
355
+ setSelectionScrollOffset(current => Math.max(0, current - selectionScrollPageSize));
356
+ return;
357
+ }
358
+ if (userActionInFlightRef.current) {
108
359
  return;
109
360
  }
110
- if (key.return && selected) {
111
- if (selected.invalidReason) {
112
- setModel(current => ({ ...current, status: { kind: 'error', message: selected.invalidReason } }));
361
+ if (key.return) {
362
+ const decision = decideEnterInteraction(selected, model.activePath);
363
+ if (decision.kind === 'ignore') {
113
364
  return;
114
365
  }
115
- if (selected.path === model.activePath) {
116
- 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 }));
371
+ clearTransientAlert();
117
372
  return;
118
373
  }
119
- setModel(current => ({ ...current, status: { kind: 'starting', message: `Starting ${selected.branch}...` } }));
120
- void apply(() => actions.start(selected.path));
374
+ setModel(current => ({ ...current, status: decision.status }));
375
+ clearTransientAlert();
376
+ void apply(() => actions.start(decision.path));
121
377
  return;
122
378
  }
123
379
  if (input === 's') {
124
380
  setModel(current => ({ ...current, status: { kind: 'stopping', message: 'Stopping active session...' } }));
381
+ clearTransientAlert();
125
382
  void apply(() => actions.stop());
126
383
  return;
127
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
+ }
128
410
  if (input === 'r') {
411
+ clearTransientAlert();
129
412
  void apply(() => actions.refresh());
130
413
  }
131
414
  });
415
+ const listPaneViewportHeight = paneHeight === undefined ? undefined : Math.max(1, paneHeight - 3);
416
+ const mouseWheelLineStep = 3;
417
+ const paneAreaLeft = 1;
418
+ const worktreePaneRight = !stackedLayout ? paneAreaLeft + listWidth - 1 : undefined;
419
+ const selectionPaneLeft = !stackedLayout && worktreePaneRight !== undefined ? worktreePaneRight + PANE_GAP_WIDTH + 1 : undefined;
420
+ const bodyPaneTop = !stackedLayout && paneHeight !== undefined ? HEADER_HEIGHT + 1 : undefined;
421
+ const bodyPaneBottom = !stackedLayout && bodyPaneTop !== undefined && paneHeight !== undefined ? bodyPaneTop + paneHeight - 1 : 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;
428
+ useEffect(() => {
429
+ const onData = (data) => {
430
+ if (isHelpOverlayOpen) {
431
+ return;
432
+ }
433
+ const events = parseMouseWheelEvents(typeof data === 'string' ? data : data.toString('utf8'));
434
+ for (const event of events) {
435
+ if (isLogOverlayOpen) {
436
+ setLogScrollOffset(current => Math.max(0, Math.min(maxLogScrollOffset, current - event.delta * mouseWheelLineStep)));
437
+ continue;
438
+ }
439
+ const isLogPaneEvent = !isLogOverlayOpen
440
+ && showLogPanel
441
+ && event.y !== undefined
442
+ && logPaneTop !== undefined
443
+ && logPaneBottom !== undefined
444
+ && event.y >= logPaneTop
445
+ && event.y <= logPaneBottom;
446
+ if (isLogPaneEvent) {
447
+ setLogScrollOffset(current => Math.max(0, Math.min(maxLogScrollOffset, current - event.delta * mouseWheelLineStep)));
448
+ continue;
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
+ }
479
+ const isBodyPaneEvent = bodyPaneTop !== undefined
480
+ && bodyPaneBottom !== undefined
481
+ && event.y !== undefined
482
+ && event.y >= bodyPaneTop
483
+ && event.y <= bodyPaneBottom;
484
+ if (!isBodyPaneEvent) {
485
+ continue;
486
+ }
487
+ const isSelectionPaneEvent = selectionPaneLeft !== undefined
488
+ && event.x !== undefined
489
+ && event.x >= selectionPaneLeft;
490
+ if (isSelectionPaneEvent) {
491
+ setSelectionScrollOffset(current => Math.max(0, current + event.delta * mouseWheelLineStep));
492
+ continue;
493
+ }
494
+ const isWorktreePaneEvent = event.x === undefined
495
+ || worktreePaneRight === undefined
496
+ || event.x <= worktreePaneRight;
497
+ if (isWorktreePaneEvent) {
498
+ scrollWorktrees();
499
+ }
500
+ }
501
+ };
502
+ if (stdout.isTTY) {
503
+ stdout.write(ENABLE_MOUSE_TRACKING);
504
+ }
505
+ stdin.on('data', onData);
506
+ return () => {
507
+ stdin.off('data', onData);
508
+ if (stdout.isTTY) {
509
+ stdout.write(DISABLE_MOUSE_TRACKING);
510
+ }
511
+ };
512
+ }, [stdin, stdout, listWidth, stackedLayout, listPaneViewportHeight, mouseWheelLineStep, model.rows.length, showLogPanel, logPaneTop, logPaneBottom, maxLogScrollOffset, worktreePaneRight, selectionPaneLeft, bodyPaneTop, bodyPaneBottom, stackedWorktreePaneTop, stackedWorktreePaneBottom, stackedSelectionPaneTop, stackedSelectionPaneBottom, isLogOverlayOpen, isHelpOverlayOpen]);
513
+ useEffect(() => {
514
+ if (listPaneViewportHeight === undefined) {
515
+ setWorktreeScrollOffset(0);
516
+ return;
517
+ }
518
+ setWorktreeScrollOffset(current => {
519
+ const max = Math.max(0, model.rows.length - listPaneViewportHeight);
520
+ if (selectedIndex < current) {
521
+ return Math.max(0, selectedIndex);
522
+ }
523
+ if (selectedIndex >= current + listPaneViewportHeight) {
524
+ return Math.max(0, Math.min(max, selectedIndex - listPaneViewportHeight + 1));
525
+ }
526
+ return Math.min(current, max);
527
+ });
528
+ }, [selectedIndex, listPaneViewportHeight, model.rows.length]);
529
+ useEffect(() => {
530
+ if (!showLogPanel && !isLogOverlayOpen) {
531
+ setLogScrollOffset(0);
532
+ return;
533
+ }
534
+ setLogScrollOffset(current => Math.min(current, maxLogScrollOffset));
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
+ }
539
+ if (isLogOverlayOpen) {
540
+ return (_jsx(FloatingLogWindow, { logs: model.logs, width: Math.max(1, rootWidth - 1), height: rootHeight, scrollOffset: logScrollOffset }));
541
+ }
132
542
  if (minimalLayout) {
133
- return (_jsxs(Box, { width: rootWidth, 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\u21B5q" }) : 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] }));
134
546
  }
135
547
  if (compactLayout) {
136
- return (_jsxs(Box, { width: rootWidth, 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 ?? '-'] }), _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 s r q \u00B7 Resize terminal for split view" })] }));
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
549
+ ? _jsxs(Text, { color: "green", wrap: "truncate-end", children: ["\u2714 ", completedAlert] })
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` })] }));
137
553
  }
138
- return (_jsxs(Box, { width: rootWidth, height: stackedLayout ? undefined : 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, stacked: stackedLayout }), _jsx(ActionPanel, { selectedRow: selected, activePath: model.activePath, stacked: stackedLayout, width: stackedLayout ? bodyWidth : actionWidth, compactDetails: compactDetailPane })] }), _jsx(ContextBar, { status: model.status })] }));
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] }));
139
555
  }
@@ -1,10 +1,14 @@
1
1
  import type { AppRow } from '../core/runtime.js';
2
+ import { type ProjectionSeverity } from '../core/worktree-projection.js';
3
+ export declare function getActionVariant(selectedRow: AppRow, activePath: string | null): ProjectionSeverity;
2
4
  export declare function getPullRequestColor(selectedRow: AppRow): 'green' | 'yellow' | 'red' | undefined;
3
- export declare function getActionColor(selectedRow: AppRow): 'yellow' | 'red' | undefined;
4
- export declare function ActionPanel({ selectedRow, activePath, stacked, width, compactDetails, }: {
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;
11
+ height?: number;
9
12
  compactDetails?: boolean;
13
+ scrollOffset?: number;
10
14
  }): import("react").JSX.Element;