@ohzw/worktree-command-tui 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,6 +3,9 @@
3
3
  `worktree-command-tui` is a terminal UI for managing Git worktrees from inside a repository.
4
4
  It keeps one active runtime session per namespace, lets you switch worktrees with the keyboard, and keeps logs/process cleanup tied to the repo's shared Git state.
5
5
 
6
+ <img width="1023" height="582" alt="image" src="https://github.com/user-attachments/assets/ef33c2aa-0af4-4701-b1ec-fa9289e8ee3a" />
7
+
8
+
6
9
  ## Features
7
10
 
8
11
  - Discover worktrees from the current repository even when launched from a subdirectory
@@ -121,8 +124,8 @@ Example config:
121
124
  // The selected worktree path is appended as the final argv entry.
122
125
  "editorCommand": ["code"],
123
126
 
124
- // TCP port owned by the command, used when stopping stale/orphaned processes.
125
- "port": 3000,
127
+ // TCP ports owned by the command, used when stopping stale/orphaned processes.
128
+ "ports": [3000],
126
129
 
127
130
  // Files that must exist in a worktree before the command can be started there.
128
131
  "requiredFiles": ["package.json"],
@@ -134,8 +137,9 @@ Example config:
134
137
 
135
138
  Notes:
136
139
 
137
- - `setupCommand` is optional and never runs automatically; `i` only appears when it is configured
140
+ - `setupCommand` is optional and never runs automatically; `i` only appears when it is configured. It accepts one argv array or an array of argv arrays executed in order. The built-in `["copy-root-file", "source", "destination"]` copies from the repo root into the selected worktree.
138
141
  - `editorCommand` is optional; when set, the selected worktree path is appended to the argv and `e` becomes available
142
+ - Use `ports` for every backend/frontend/dev-server port the command may bind; legacy single `port` configs still load
139
143
  - The generated default config auto-detects package manager hints from `packageManager` or common lockfiles and chooses a default script such as `dev`, `start`, or `serve`
140
144
  - Session records and logs are stored under the repository's Git common dir, so they are shared across worktrees in the same repo
141
145
 
package/dist/app.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AppActions, AppModel } from './core/runtime.js';
1
+ import type { AppActions, AppLogRefresh, AppModel } from './core/runtime.js';
2
2
  export interface ShellDimensions {
3
3
  rootWidth: number;
4
4
  rootHeight: number;
@@ -14,9 +14,12 @@ 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 const RESIZE_DEBOUNCE_MS = 100;
17
18
  export declare function shouldStackPanes(columns: number, rows: number, _worktreeCount?: number): boolean;
18
- export declare function App({ initialModel, actions, windowSizeOverride, }: {
19
+ export declare function getModelAfterLogRefresh(current: AppModel, refresh: AppLogRefresh): AppModel;
20
+ export declare function App({ initialModel, actions, windowSizeOverride, resizeDebounceMs, }: {
19
21
  initialModel: AppModel;
20
22
  actions: AppActions;
21
23
  windowSizeOverride?: AppWindowSize;
24
+ resizeDebounceMs?: number;
22
25
  }): import("react").JSX.Element;
package/dist/app.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Alert, Spinner } from '@inkjs/ui';
3
- import { useEffect, useMemo, useRef, useState } from 'react';
4
- import { Box, Text, useApp, useInput, useStdin, useStdout, useWindowSize } from 'ink';
3
+ import { memo, useEffect, useMemo, useRef, useState } from 'react';
4
+ import { Box, Text, useApp, useInput, useStdin, useStdout } from 'ink';
5
5
  import { ActionPanel } from './components/ActionPanel.js';
6
6
  import { ContextBar } from './components/ContextBar.js';
7
7
  import { Header } from './components/Header.js';
@@ -9,7 +9,7 @@ import { HelpWindow } from './components/HelpWindow.js';
9
9
  import { FloatingLogWindow } from './components/FloatingLogWindow.js';
10
10
  import { LogPanel, buildLogLines } from './components/LogPanel.js';
11
11
  import { WorktreeList } from './components/WorktreeList.js';
12
- import { clampSelectionIndex, decideEnterInteraction, decideSetupInteraction, getNextSelectedPath, getSelectedIndex } from './core/tui-interaction.js';
12
+ import { clampSelectionIndex, decideEnterInteraction, decideSetupInteraction, getNextSelectedPath, getSelectedIndex, wrapSelectionIndex } from './core/tui-interaction.js';
13
13
  import { sanitizeInlineText } from './core/worktree-projection.js';
14
14
  const ENABLE_MOUSE_TRACKING = '\u001B[?1000h\u001B[?1006h';
15
15
  const DISABLE_MOUSE_TRACKING = '\u001B[?1000l\u001B[?1006l';
@@ -55,6 +55,7 @@ const HEADER_HEIGHT = 5;
55
55
  const CONTEXT_BAR_HEIGHT = 4;
56
56
  const PANE_GAP_WIDTH = 1;
57
57
  const MIN_STACKED_PANE_HEIGHT = 9;
58
+ export const RESIZE_DEBOUNCE_MS = 100;
58
59
  export function shouldStackPanes(columns, rows, _worktreeCount = 0) {
59
60
  // Header + context bar consume the fixed chrome; each stacked pane still keeps ~6 visible content lines at the minimum height.
60
61
  return columns < 96 && rows >= STACKED_LAYOUT_FRAME_ROWS + (MIN_STACKED_PANE_HEIGHT * 2);
@@ -81,6 +82,25 @@ function syncActiveTags(rows, activePath) {
81
82
  });
82
83
  return changed ? nextRows : rows;
83
84
  }
85
+ function rowMatchesFilter(row, normalizedQuery) {
86
+ if (normalizedQuery === '') {
87
+ return true;
88
+ }
89
+ const pullRequest = row.pullRequest?.kind === 'found' ? row.pullRequest : null;
90
+ const headCommit = `${row.headSha ?? ''} ${row.headCommit?.message ?? ''}`.toLowerCase();
91
+ return row.branch.toLowerCase().includes(normalizedQuery)
92
+ || row.path.toLowerCase().includes(normalizedQuery)
93
+ || row.shortPath.toLowerCase().includes(normalizedQuery)
94
+ || headCommit.includes(normalizedQuery)
95
+ || (pullRequest !== null && (`${pullRequest.number} ${pullRequest.title}`).toLowerCase().includes(normalizedQuery));
96
+ }
97
+ function filterRows(rows, query) {
98
+ const normalizedQuery = query.trim().toLowerCase();
99
+ if (normalizedQuery === '') {
100
+ return rows;
101
+ }
102
+ return rows.filter(row => rowMatchesFilter(row, normalizedQuery));
103
+ }
84
104
  function shouldRefreshLogs(model) {
85
105
  return model.activePath !== null && (model.status.kind === 'running' || model.status.kind === 'error');
86
106
  }
@@ -99,14 +119,104 @@ function getStatusAfterLogRefresh(current, refresh) {
99
119
  }
100
120
  return { kind: 'running', message: 'running' };
101
121
  }
102
- export function App({ initialModel, actions, windowSizeOverride, }) {
122
+ function areLogsEqual(left, right) {
123
+ if (left.length !== right.length) {
124
+ return false;
125
+ }
126
+ return left.every((entry, index) => {
127
+ const other = right[index];
128
+ return other !== undefined
129
+ && entry.name === other.name
130
+ && entry.path === other.path
131
+ && entry.content === other.content;
132
+ });
133
+ }
134
+ export function getModelAfterLogRefresh(current, refresh) {
135
+ const activeChanged = current.activePath !== refresh.activePath || current.activeBranch !== refresh.activeBranch;
136
+ const status = getStatusAfterLogRefresh(current, refresh);
137
+ const logsChanged = !areLogsEqual(current.logs, refresh.logs);
138
+ const statusChanged = current.status.kind !== status.kind || current.status.message !== status.message;
139
+ const rows = activeChanged ? syncActiveTags(current.rows, refresh.activePath) : current.rows;
140
+ if (!activeChanged && !logsChanged && !statusChanged) {
141
+ return current;
142
+ }
143
+ return {
144
+ ...current,
145
+ logs: refresh.logs,
146
+ activePath: refresh.activePath,
147
+ activeBranch: refresh.activeBranch,
148
+ status,
149
+ rows,
150
+ };
151
+ }
152
+ function readWindowSize(stdout) {
153
+ return {
154
+ columns: stdout.columns || process.stdout.columns || 80,
155
+ rows: stdout.rows || process.stdout.rows || 24,
156
+ };
157
+ }
158
+ function useDebouncedWindowSize(windowSizeOverride, debounceMs) {
159
+ const { stdout } = useStdout();
160
+ const resizeTimerRef = useRef(undefined);
161
+ const [stableWindowSize, setStableWindowSize] = useState(() => windowSizeOverride ?? readWindowSize(stdout));
162
+ useEffect(() => {
163
+ return () => {
164
+ clearTimeout(resizeTimerRef.current);
165
+ };
166
+ }, []);
167
+ useEffect(() => {
168
+ if (windowSizeOverride === undefined) {
169
+ return;
170
+ }
171
+ clearTimeout(resizeTimerRef.current);
172
+ if (stableWindowSize.columns === windowSizeOverride.columns && stableWindowSize.rows === windowSizeOverride.rows) {
173
+ return;
174
+ }
175
+ resizeTimerRef.current = setTimeout(() => {
176
+ resizeTimerRef.current = undefined;
177
+ setStableWindowSize({ columns: windowSizeOverride.columns, rows: windowSizeOverride.rows });
178
+ }, debounceMs);
179
+ }, [windowSizeOverride?.columns, windowSizeOverride?.rows, debounceMs, stableWindowSize.columns, stableWindowSize.rows]);
180
+ useEffect(() => {
181
+ if (windowSizeOverride !== undefined) {
182
+ return;
183
+ }
184
+ const scheduleResize = () => {
185
+ const nextWindowSize = readWindowSize(stdout);
186
+ clearTimeout(resizeTimerRef.current);
187
+ if (stableWindowSize.columns === nextWindowSize.columns && stableWindowSize.rows === nextWindowSize.rows) {
188
+ return;
189
+ }
190
+ resizeTimerRef.current = setTimeout(() => {
191
+ resizeTimerRef.current = undefined;
192
+ setStableWindowSize(nextWindowSize);
193
+ }, debounceMs);
194
+ };
195
+ scheduleResize();
196
+ stdout.on('resize', scheduleResize);
197
+ return () => {
198
+ stdout.off('resize', scheduleResize);
199
+ };
200
+ }, [stdout, windowSizeOverride, debounceMs, stableWindowSize.columns, stableWindowSize.rows]);
201
+ return stableWindowSize;
202
+ }
203
+ export function App({ initialModel, actions, windowSizeOverride, resizeDebounceMs, }) {
204
+ const stableWindowSize = useDebouncedWindowSize(windowSizeOverride, resizeDebounceMs ?? RESIZE_DEBOUNCE_MS);
205
+ return _jsx(MemoizedAppShell, { initialModel: initialModel, actions: actions, windowSize: stableWindowSize });
206
+ }
207
+ function AppShell(props) {
208
+ return _jsx(AppShellContent, { ...props });
209
+ }
210
+ function AppShellContent({ initialModel, actions, windowSize, }) {
103
211
  const { exit } = useApp();
104
212
  const { stdin } = useStdin();
105
213
  const { stdout } = useStdout();
106
- const liveWindowSize = useWindowSize();
107
- const { columns, rows } = windowSizeOverride ?? liveWindowSize;
214
+ const { columns, rows } = windowSize;
108
215
  const [model, setModel] = useState(initialModel);
109
- const [selectedPath, setSelectedPath] = useState(initialModel.rows[0]?.path ?? null);
216
+ const [filterQuery, setFilterQuery] = useState('');
217
+ const [isFilterInputOpen, setIsFilterInputOpen] = useState(false);
218
+ const visibleRows = useMemo(() => filterRows(model.rows, filterQuery), [model.rows, filterQuery]);
219
+ const [selectedPath, setSelectedPath] = useState(visibleRows[0]?.path ?? null);
110
220
  const [selectionScrollOffset, setSelectionScrollOffset] = useState(0);
111
221
  const [worktreeScrollOffset, setWorktreeScrollOffset] = useState(0);
112
222
  const [logScrollOffset, setLogScrollOffset] = useState(0);
@@ -121,8 +231,8 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
121
231
  const lastStartRequestRef = useRef({ path: null, atMs: 0 });
122
232
  const alertTimeoutRef = useRef(null);
123
233
  useEffect(() => {
124
- setSelectedPath(currentPath => getNextSelectedPath(model.rows, currentPath));
125
- }, [model.rows]);
234
+ setSelectedPath(currentPath => getNextSelectedPath(visibleRows, currentPath));
235
+ }, [visibleRows]);
126
236
  useEffect(() => {
127
237
  setSelectionScrollOffset(0);
128
238
  }, [selectedPath]);
@@ -169,18 +279,7 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
169
279
  if (generation !== actionGenerationRef.current || userActionInFlightRef.current) {
170
280
  return;
171
281
  }
172
- setModel(current => {
173
- const activeChanged = current.activePath !== refresh.activePath || current.activeBranch !== refresh.activeBranch;
174
- const status = getStatusAfterLogRefresh(current, refresh);
175
- return {
176
- ...current,
177
- logs: refresh.logs,
178
- activePath: refresh.activePath,
179
- activeBranch: refresh.activeBranch,
180
- status,
181
- rows: activeChanged ? syncActiveTags(current.rows, refresh.activePath) : current.rows,
182
- };
183
- });
282
+ setModel(current => getModelAfterLogRefresh(current, refresh));
184
283
  })
185
284
  .catch(() => { })
186
285
  .finally(() => {
@@ -196,16 +295,16 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
196
295
  setPendingDelete(null);
197
296
  }
198
297
  }, [confirmationOpen, model.rows, pendingDelete]);
199
- const selectedIndex = useMemo(() => getSelectedIndex(model.rows, selectedPath), [model.rows, selectedPath]);
200
- const selected = model.rows[selectedIndex];
298
+ const selectedIndex = useMemo(() => getSelectedIndex(visibleRows, selectedPath), [visibleRows, selectedPath]);
299
+ const selected = visibleRows[selectedIndex];
201
300
  const { rootWidth, rootHeight, bodyWidth, listWidth, actionWidth } = getShellDimensions(columns, rows);
202
301
  const minimalLayout = shouldUseMinimalLayout(rootWidth, rootHeight);
203
- const compactLayout = !minimalLayout && shouldUseCompactLayout(rootWidth, rootHeight, model.rows.length);
204
- const stackedLayout = !minimalLayout && !compactLayout && shouldStackPanes(rootWidth, rootHeight, model.rows.length);
205
- const compactDetailPane = !stackedLayout && rootHeight <= 30 && model.rows.length > 1;
206
- const showLogPanel = !stackedLayout && rootHeight >= 34;
302
+ const compactLayout = !minimalLayout && shouldUseCompactLayout(rootWidth, rootHeight, visibleRows.length);
303
+ const stackedLayout = !minimalLayout && !compactLayout && shouldStackPanes(rootWidth, rootHeight, visibleRows.length);
304
+ const compactDetailPane = !stackedLayout && rootHeight <= 30 && visibleRows.length > 1;
305
+ const showLogPanel = rootHeight >= 34;
207
306
  const logPaneHeight = showLogPanel ? getLogPaneHeight(rootHeight) : 0;
208
- const stackedPaneHeight = Math.max(3, Math.floor((rootHeight - STACKED_LAYOUT_FRAME_ROWS) / 2));
307
+ const stackedPaneHeight = Math.max(3, Math.floor((rootHeight - STACKED_LAYOUT_FRAME_ROWS - logPaneHeight) / 2));
209
308
  const paneHeight = stackedLayout
210
309
  ? stackedPaneHeight
211
310
  : Math.max(3, rootHeight - STACKED_LAYOUT_FRAME_ROWS - logPaneHeight);
@@ -216,12 +315,18 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
216
315
  : showLogPanel ? Math.max(1, logPaneHeight - 3) : 0;
217
316
  const maxLogScrollOffset = Math.max(0, logLineCount - logViewportHeight);
218
317
  const logScrollPageSize = Math.max(1, Math.floor((logViewportHeight || rootHeight) / 2));
219
- function moveSelection(nextIndex) {
220
- const clampedIndex = clampSelectionIndex(nextIndex, model.rows.length);
221
- if (clampedIndex === null) {
318
+ function moveSelection(nextIndex, mode = 'clamp') {
319
+ const nextSelectionIndex = mode === 'wrap'
320
+ ? wrapSelectionIndex(nextIndex, visibleRows.length)
321
+ : clampSelectionIndex(nextIndex, visibleRows.length);
322
+ if (nextSelectionIndex === null) {
222
323
  return;
223
324
  }
224
- setSelectedPath(model.rows[clampedIndex].path);
325
+ setSelectedPath(visibleRows[nextSelectionIndex].path);
326
+ }
327
+ function clearFilter() {
328
+ setFilterQuery('');
329
+ setIsFilterInputOpen(false);
225
330
  }
226
331
  function clearTransientAlert() {
227
332
  if (alertTimeoutRef.current !== null) {
@@ -262,6 +367,29 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
262
367
  last.atMs = nowMs;
263
368
  return true;
264
369
  }
370
+ function startSelectedWorktree() {
371
+ if (userActionInFlightRef.current) {
372
+ return;
373
+ }
374
+ const decision = decideEnterInteraction(selected, model.activePath);
375
+ if (decision.kind === 'ignore') {
376
+ return;
377
+ }
378
+ if (decision.kind === 'set-status') {
379
+ if (decision.suppressesBackgroundRefreshes) {
380
+ invalidateStaleLogRefreshes();
381
+ }
382
+ setModel(current => ({ ...current, status: decision.status }));
383
+ clearTransientAlert();
384
+ return;
385
+ }
386
+ if (!shouldAcceptStartRequest(decision.path)) {
387
+ return;
388
+ }
389
+ setModel(current => ({ ...current, status: decision.status }));
390
+ clearTransientAlert();
391
+ void apply(() => actions.start(decision.path));
392
+ }
265
393
  useInput((input, key) => {
266
394
  if (isHelpOverlayOpen) {
267
395
  if (key.escape || input === '\u001B' || input === 'q' || input === '?') {
@@ -318,16 +446,54 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
318
446
  }
319
447
  return;
320
448
  }
449
+ if (isFilterInputOpen) {
450
+ if (key.escape || input === '\u001B') {
451
+ clearFilter();
452
+ return;
453
+ }
454
+ if (key.upArrow) {
455
+ moveSelection(selectedIndex - 1, 'wrap');
456
+ return;
457
+ }
458
+ if (key.downArrow) {
459
+ moveSelection(selectedIndex + 1, 'wrap');
460
+ return;
461
+ }
462
+ if (key.pageDown) {
463
+ setSelectionScrollOffset(current => current + selectionScrollPageSize);
464
+ return;
465
+ }
466
+ if (key.pageUp) {
467
+ setSelectionScrollOffset(current => Math.max(0, current - selectionScrollPageSize));
468
+ return;
469
+ }
470
+ if (key.backspace || key.delete) {
471
+ setFilterQuery(current => current.slice(0, -1));
472
+ return;
473
+ }
474
+ if (key.return) {
475
+ startSelectedWorktree();
476
+ return;
477
+ }
478
+ if (input && !key.ctrl && !key.meta) {
479
+ setFilterQuery(current => current + input);
480
+ }
481
+ return;
482
+ }
483
+ if (input === '/') {
484
+ setIsFilterInputOpen(true);
485
+ return;
486
+ }
321
487
  if (key.escape || input === 'q') {
322
488
  exit();
323
489
  return;
324
490
  }
325
491
  if (key.upArrow || input === 'k') {
326
- moveSelection(selectedIndex - 1);
492
+ moveSelection(selectedIndex - 1, 'wrap');
327
493
  return;
328
494
  }
329
495
  if (key.downArrow || input === 'j') {
330
- moveSelection(selectedIndex + 1);
496
+ moveSelection(selectedIndex + 1, 'wrap');
331
497
  return;
332
498
  }
333
499
  if (input === 'g') {
@@ -335,7 +501,7 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
335
501
  return;
336
502
  }
337
503
  if (input === 'G') {
338
- moveSelection(model.rows.length - 1);
504
+ moveSelection(visibleRows.length - 1);
339
505
  return;
340
506
  }
341
507
  if (input === '?') {
@@ -362,28 +528,8 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
362
528
  setSelectionScrollOffset(current => Math.max(0, current - selectionScrollPageSize));
363
529
  return;
364
530
  }
365
- if (userActionInFlightRef.current) {
366
- return;
367
- }
368
531
  if (key.return) {
369
- const decision = decideEnterInteraction(selected, model.activePath);
370
- if (decision.kind === 'ignore') {
371
- return;
372
- }
373
- if (decision.kind === 'set-status') {
374
- if (decision.suppressesBackgroundRefreshes) {
375
- invalidateStaleLogRefreshes();
376
- }
377
- setModel(current => ({ ...current, status: decision.status }));
378
- clearTransientAlert();
379
- return;
380
- }
381
- if (!shouldAcceptStartRequest(decision.path)) {
382
- return;
383
- }
384
- setModel(current => ({ ...current, status: decision.status }));
385
- clearTransientAlert();
386
- void apply(() => actions.start(decision.path));
532
+ startSelectedWorktree();
387
533
  return;
388
534
  }
389
535
  if (input === 's') {
@@ -462,7 +608,7 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
462
608
  if (listPaneViewportHeight === undefined) {
463
609
  return 0;
464
610
  }
465
- const max = Math.max(0, model.rows.length - listPaneViewportHeight);
611
+ const max = Math.max(0, visibleRows.length - listPaneViewportHeight);
466
612
  return Math.max(0, Math.min(max, current + event.delta * mouseWheelLineStep));
467
613
  });
468
614
  };
@@ -519,14 +665,14 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
519
665
  stdout.write(DISABLE_MOUSE_TRACKING);
520
666
  }
521
667
  };
522
- }, [stdin, stdout, listWidth, stackedLayout, listPaneViewportHeight, mouseWheelLineStep, model.rows.length, showLogPanel, logPaneTop, logPaneBottom, maxLogScrollOffset, worktreePaneRight, selectionPaneLeft, bodyPaneTop, bodyPaneBottom, stackedWorktreePaneTop, stackedWorktreePaneBottom, stackedSelectionPaneTop, stackedSelectionPaneBottom, isLogOverlayOpen, isHelpOverlayOpen]);
668
+ }, [stdin, stdout, listWidth, stackedLayout, listPaneViewportHeight, mouseWheelLineStep, visibleRows.length, showLogPanel, logPaneTop, logPaneBottom, maxLogScrollOffset, worktreePaneRight, selectionPaneLeft, bodyPaneTop, bodyPaneBottom, stackedWorktreePaneTop, stackedWorktreePaneBottom, stackedSelectionPaneTop, stackedSelectionPaneBottom, isLogOverlayOpen, isHelpOverlayOpen]);
523
669
  useEffect(() => {
524
670
  if (listPaneViewportHeight === undefined) {
525
671
  setWorktreeScrollOffset(0);
526
672
  return;
527
673
  }
528
674
  setWorktreeScrollOffset(current => {
529
- const max = Math.max(0, model.rows.length - listPaneViewportHeight);
675
+ const max = Math.max(0, visibleRows.length - listPaneViewportHeight);
530
676
  if (selectedIndex < current) {
531
677
  return Math.max(0, selectedIndex);
532
678
  }
@@ -535,7 +681,7 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
535
681
  }
536
682
  return Math.min(current, max);
537
683
  });
538
- }, [selectedIndex, listPaneViewportHeight, model.rows.length]);
684
+ }, [selectedIndex, listPaneViewportHeight, visibleRows.length]);
539
685
  useEffect(() => {
540
686
  if (!showLogPanel && !isLogOverlayOpen) {
541
687
  setLogScrollOffset(0);
@@ -557,14 +703,15 @@ export function App({ initialModel, actions, windowSizeOverride, }) {
557
703
  if (minimalLayout) {
558
704
  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
559
705
  ? _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "d/y confirm \u00B7 Esc/n/q cancel" })
560
- : _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193jk\u21B5", model.setupAvailable ? 'i' : '', model.editorAvailable ? 'e' : '', "odLq"] })) : null] }));
706
+ : _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193jk/\u21B5", model.setupAvailable ? 'i' : '', model.editorAvailable ? 'e' : '', "odLq"] })) : null] }));
561
707
  }
562
708
  if (compactLayout) {
563
709
  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
564
710
  ? _jsxs(Text, { color: "green", wrap: "truncate-end", children: ["\u2714 ", safeCompletedAlert] })
565
711
  : 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
566
712
  ? 'Keys: d/y confirm | Esc/n/q cancel'
567
- : `Keys: ↑↓/jk g/G ↵${model.setupAvailable ? ' i' : ''}${model.editorAvailable ? ' e' : ''} o d L s r q · Resize terminal for split view` })] }));
713
+ : `Keys: ↑↓/jk g/G / Filter ↵${model.setupAvailable ? ' i' : ''}${model.editorAvailable ? ' e' : ''} o d L s r q · Resize terminal for split view` })] }));
568
714
  }
569
- 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] }));
715
+ 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: visibleRows, selectedIndex: selectedIndex, width: stackedLayout ? bodyWidth : listWidth, height: paneHeight, stacked: stackedLayout, scrollOffset: worktreeScrollOffset, filterQuery: filterQuery, isFilterInputOpen: isFilterInputOpen, totalRowCount: model.rows.length }), _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] }));
570
716
  }
717
+ const MemoizedAppShell = memo(AppShell);
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- import { getOrderedNonActiveTags, projectAction, projectNote, projectPullRequest, projectUpstream, projectWorkingTree, sanitizeInlineText, } from '../core/worktree-projection.js';
3
+ import { getOrderedNonActiveTags, projectAction, projectHeadCommit, projectNote, projectPullRequest, projectUpstream, projectWorkingTree, sanitizeInlineText, } from '../core/worktree-projection.js';
4
4
  import { getScrollbarThumbRows, sliceListViewport } from '../terminal/viewport.js';
5
5
  export function getActionVariant(selectedRow, activePath) {
6
6
  return projectAction(selectedRow, activePath).severity;
@@ -72,6 +72,13 @@ function formatPullRequest(selectedRow) {
72
72
  const stateText = pullRequest.state.toLowerCase();
73
73
  return `#${pullRequest.number} ${draft}${stateText} → ${pullRequest.baseBranch}`;
74
74
  }
75
+ function formatHeadCommit(selectedRow) {
76
+ const headCommit = projectHeadCommit(selectedRow);
77
+ if (headCommit.kind === 'unavailable') {
78
+ return '-';
79
+ }
80
+ return headCommit.label;
81
+ }
75
82
  function getPullRequestColorFromProjection(pullRequest) {
76
83
  if (pullRequest.kind === 'unavailable') {
77
84
  return 'red';
@@ -150,7 +157,7 @@ function getPanelLines(selectedRow, activePath, setupAvailable, compactDetails)
150
157
  if (showFullPath) {
151
158
  lines.push({ text: `Full Path: ${sanitizeInlineText(selectedRow.path)}` });
152
159
  }
153
- lines.push({ text: `HEAD: ${selectedRow.headSha || '-'}` });
160
+ lines.push({ text: `HEAD: ${formatHeadCommit(selectedRow)}` });
154
161
  lines.push({ text: `Branch Created: ${formatUtcDateTime(selectedRow.branchCreatedAtMs)}` });
155
162
  if (showTags) {
156
163
  for (const { tag } of getOrderedNonActiveTags(selectedRow.tags)) {
@@ -30,6 +30,7 @@ function buildKeyHints(setupAvailable, editorAvailable, confirmationOpen) {
30
30
  { binding: '↑↓/jk', label: 'Move' },
31
31
  { binding: 'Enter', label: 'Switch' },
32
32
  ];
33
+ hints.push({ binding: '/', label: 'Filter' });
33
34
  if (setupAvailable) {
34
35
  hints.push({ binding: 'i', label: 'Setup' });
35
36
  }
@@ -9,6 +9,8 @@ function buildHelpLines(setupAvailable, editorAvailable) {
9
9
  { section: 'Logs', binding: 'L', description: 'full-screen logs' },
10
10
  { section: 'Logs', binding: '[/]', description: 'scroll log' },
11
11
  { section: 'Actions', binding: 'Enter', description: 'start/switch/restart worktree' },
12
+ { section: 'Filter', binding: '/', description: 'filter by branch, path, or pull request' },
13
+ { section: 'Filter', binding: 'Esc', description: 'clear filter' },
12
14
  ];
13
15
  if (setupAvailable) {
14
16
  lines.push({ section: 'Actions', binding: 'i', description: 'setup selected worktree' });
@@ -1,9 +1,12 @@
1
1
  import type { AppRow } from '../core/runtime.js';
2
- export declare function WorktreeList({ rows, selectedIndex, width, height, stacked, scrollOffset, }: {
2
+ export declare function WorktreeList({ rows, selectedIndex, width, height, stacked, scrollOffset, filterQuery, isFilterInputOpen, totalRowCount, }: {
3
3
  rows: AppRow[];
4
4
  selectedIndex: number;
5
5
  width?: number;
6
6
  height?: number;
7
7
  stacked: boolean;
8
8
  scrollOffset?: number;
9
+ filterQuery?: string;
10
+ isFilterInputOpen?: boolean;
11
+ totalRowCount?: number;
9
12
  }): import("react").JSX.Element;
@@ -33,7 +33,7 @@ function truncateLabel(value, width) {
33
33
  }
34
34
  return `${value.slice(0, Math.max(width - 1, 0))}…`;
35
35
  }
36
- export function WorktreeList({ rows, selectedIndex, width, height, stacked, scrollOffset = 0, }) {
36
+ export function WorktreeList({ rows, selectedIndex, width, height, stacked, scrollOffset = 0, filterQuery = '', isFilterInputOpen = false, totalRowCount = rows.length, }) {
37
37
  const branchWidth = Math.max(MIN_BRANCH_WIDTH, (width ?? 34) - 7);
38
38
  const viewport = sliceListViewport(rows, height === undefined ? rows.length : height - 3, scrollOffset);
39
39
  const contentViewportHeight = viewport.viewportHeight;
@@ -41,7 +41,12 @@ export function WorktreeList({ rows, selectedIndex, width, height, stacked, scro
41
41
  const visibleRows = viewport.visibleItems;
42
42
  const showScrollbar = height !== undefined && rows.length > contentViewportHeight;
43
43
  const scrollbarThumbRows = showScrollbar ? getScrollbarThumbRows(rows.length, contentViewportHeight, effectiveScrollOffset) : new Set();
44
- return (_jsxs(Box, { width: width, height: height, flexGrow: stacked ? 0 : 1, marginRight: stacked ? 0 : 1, borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, overflowY: "hidden", children: [_jsx(Text, { bold: true, color: "cyan", children: "Worktrees" }), visibleRows.map((row, index) => {
44
+ const hasFilter = filterQuery.trim() !== '';
45
+ const filterText = sanitizeInlineText(filterQuery);
46
+ const title = hasFilter || isFilterInputOpen
47
+ ? `Worktrees /${filterText}${isFilterInputOpen ? '█' : ''} (${rows.length}/${totalRowCount})`
48
+ : 'Worktrees';
49
+ return (_jsxs(Box, { width: width, height: height, flexGrow: stacked ? 0 : 1, marginRight: stacked ? 0 : 1, borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1, overflowY: "hidden", children: [_jsx(Text, { bold: true, color: "cyan", wrap: "truncate-end", children: title }), rows.length === 0 ? (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: "No worktrees match filter." })) : null, visibleRows.map((row, index) => {
45
50
  const isSelected = index + effectiveScrollOffset === selectedIndex;
46
51
  const projection = projectWorktreeListRow(row, isSelected);
47
52
  const tagSuffix = projection.isMain ? ' [root]' : '';
@@ -4,8 +4,9 @@ interface CommandLogOptions {
4
4
  logsDir: string;
5
5
  logFileBase: string;
6
6
  errorLabel?: string;
7
+ workspaceRoot?: string;
7
8
  }
8
- export declare function runCommandToLog({ command, cwd, logsDir, logFileBase, errorLabel, }: CommandLogOptions): Promise<{
9
+ export declare function runCommandToLog({ command, cwd, logsDir, logFileBase, errorLabel, workspaceRoot, }: CommandLogOptions): Promise<{
9
10
  logPath: string;
10
11
  }>;
11
12
  export declare function startDetachedCommand({ command, cwd, logsDir, logFileBase, }: {