@ohzw/worktree-command-tui 0.1.1 → 0.1.3

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