recomposable 1.1.3 → 1.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Eliminate switching between countless terminal tabs or windows to rebuild you do
6
6
 
7
7
  Zero dependencies. Pure Node.js.
8
8
 
9
- ![recomposable list view](screenshots/list-view.png)
9
+ ![recomposable demo](screenshots/demo.gif)
10
10
 
11
11
  ## Install
12
12
 
@@ -48,6 +48,7 @@ recomposable
48
48
  - **Docker Compose Watch** — toggle `docker compose watch` per service, with live output in the log panel
49
49
  - **Dependency-aware rebuild** — rebuild a service then automatically restart all its transitive dependents in topological order
50
50
  - **Container exec** — run commands inside any container, inline in the bottom panel (`e`) or full-screen (`x`), with `cd` support and command history
51
+ - **Worktree switching** — switch any service to run from a different git worktree (`t`), automatically rebuilds and starts in the target branch
51
52
  - **Vim keybindings** — navigate with `j`/`k`, `G`/`gg`, and more
52
53
 
53
54
  ## Full Log View
@@ -68,6 +69,12 @@ Press `w` to toggle `docker compose watch` for a service. A cyan `W` indicator a
68
69
 
69
70
  Press `d` to rebuild the selected service and then automatically restart all services that depend on it (transitively), in the correct topological order. Progress is shown step-by-step in the log panel. If the service has no dependents, falls back to a regular rebuild.
70
71
 
72
+ ## Worktree Switching
73
+
74
+ Press `t` on any service to switch it to a different git worktree. A picker shows all available worktrees — navigate with `j`/`k`, confirm with `Enter`. The service is automatically stopped, rebuilt, and started from the target worktree's compose file. A `WORKTREE` column appears when services run from multiple branches, with non-main branches highlighted in yellow.
75
+
76
+ This is useful for end-to-end testing changes across branches without drowning in terminal tabs. Run your main stack on `main`, then switch individual services to feature branches to verify their behavior in the full environment. Particularly handy when letting Claude Code work in worktrees — switch the affected service, verify it end-to-end, and switch back, all from a single terminal.
77
+
71
78
  ## Adding Compose Files
72
79
 
73
80
  Create a `recomposable.json` file in your project root:
@@ -136,6 +143,7 @@ recomposable -f docker-compose.yml -f docker-compose.prod.yml
136
143
  | `x` | Full-screen exec mode |
137
144
  | `n` | Toggle no-cache mode (rebuild with `--no-cache` + `--force-recreate`) |
138
145
  | `f` / `Enter` | Full-screen log view for selected service |
146
+ | `t` | Switch service to a different git worktree |
139
147
  | `l` | Toggle inline log panel |
140
148
  | `/` | Search in inline log panel |
141
149
  | `G` | Jump to bottom |
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import type { Config, AppState, ServiceGroup } from './lib/types';
2
+ import type { Config, AppState, ServiceGroup, GitWorktree } from './lib/types';
3
3
  export interface ModuleState {
4
4
  logScanActive: boolean;
5
5
  statsPollActive: boolean;
@@ -11,6 +11,7 @@ export declare function createModuleState(): ModuleState;
11
11
  export declare function loadConfig(): Config;
12
12
  export declare function discoverServices(config: Config): ServiceGroup[];
13
13
  export declare function pollStatuses(state: AppState): void;
14
+ export declare function detectMultipleWorktrees(state: AppState): void;
14
15
  export declare function pollLogCounts(state: AppState): void;
15
16
  export declare function pollContainerStats(state: AppState): void;
16
17
  export declare function render(state: AppState): void;
@@ -21,6 +22,9 @@ export declare function doRebuild(state: AppState): void;
21
22
  export declare function doRestart(state: AppState): void;
22
23
  export declare function doStop(state: AppState): void;
23
24
  export declare function doStart(state: AppState): void;
25
+ export declare function mapComposeFileToWorktree(composeFile: string, targetWorktreePath: string): string | null;
26
+ export declare function openWorktreePicker(state: AppState): void;
27
+ export declare function doWorktreeSwitch(state: AppState, targetWorktree: GitWorktree): void;
24
28
  export declare function doWatch(state: AppState): void;
25
29
  export declare function initDepGraphs(state: AppState): void;
26
30
  export declare function doCascadeRebuild(state: AppState): void;
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ exports.createModuleState = createModuleState;
8
8
  exports.loadConfig = loadConfig;
9
9
  exports.discoverServices = discoverServices;
10
10
  exports.pollStatuses = pollStatuses;
11
+ exports.detectMultipleWorktrees = detectMultipleWorktrees;
11
12
  exports.pollLogCounts = pollLogCounts;
12
13
  exports.pollContainerStats = pollContainerStats;
13
14
  exports.render = render;
@@ -18,6 +19,9 @@ exports.doRebuild = doRebuild;
18
19
  exports.doRestart = doRestart;
19
20
  exports.doStop = doStop;
20
21
  exports.doStart = doStart;
22
+ exports.mapComposeFileToWorktree = mapComposeFileToWorktree;
23
+ exports.openWorktreePicker = openWorktreePicker;
24
+ exports.doWorktreeSwitch = doWorktreeSwitch;
21
25
  exports.doWatch = doWatch;
22
26
  exports.initDepGraphs = initDepGraphs;
23
27
  exports.doCascadeRebuild = doCascadeRebuild;
@@ -60,7 +64,7 @@ function loadConfig() {
60
64
  composeFiles: [],
61
65
  pollInterval: 3000,
62
66
  logTailLines: 100,
63
- logScanPatterns: ['WRN]', 'ERR]'],
67
+ logScanPatterns: [['WRN]', 'WARNING'], ['ERR]', 'ERROR']],
64
68
  logScanLines: 1000,
65
69
  logScanInterval: 10000,
66
70
  statsInterval: 5000,
@@ -78,7 +82,7 @@ function loadConfig() {
78
82
  if (Array.isArray(raw.composeFiles) && raw.composeFiles.every((f) => typeof f === 'string')) {
79
83
  defaults.composeFiles = raw.composeFiles;
80
84
  }
81
- if (Array.isArray(raw.logScanPatterns) && raw.logScanPatterns.every((p) => typeof p === 'string')) {
85
+ if (Array.isArray(raw.logScanPatterns) && raw.logScanPatterns.every((p) => typeof p === 'string' || (Array.isArray(p) && p.length > 0 && p.every((s) => typeof s === 'string')))) {
82
86
  defaults.logScanPatterns = raw.logScanPatterns;
83
87
  }
84
88
  const numericFields = [
@@ -138,14 +142,41 @@ function discoverServices(config) {
138
142
  }
139
143
  // --- Status Polling ---
140
144
  function pollStatuses(state) {
145
+ // Collect services by their effective file (may differ from group file due to worktree overrides)
146
+ const fileToServices = new Map();
141
147
  for (const group of state.groups) {
142
148
  if (group.error)
143
149
  continue;
144
- const statuses = (0, docker_1.getStatuses)(group.file);
150
+ for (const service of group.services) {
151
+ const sk = (0, state_1.statusKey)(group.file, service);
152
+ const file = (0, state_1.getEffectiveFile)(state, group.file, service);
153
+ if (!fileToServices.has(file))
154
+ fileToServices.set(file, []);
155
+ fileToServices.get(file).push({ sk, service });
156
+ }
157
+ }
158
+ for (const [file, services] of fileToServices) {
159
+ const statuses = (0, docker_1.getStatuses)(file);
160
+ const serviceSet = new Set(services.map(s => s.service));
145
161
  for (const [svc, st] of statuses) {
146
- state.statuses.set((0, state_1.statusKey)(group.file, svc), st);
162
+ if (serviceSet.has(svc)) {
163
+ // Store under the original statusKey (group.file based)
164
+ const match = services.find(s => s.service === svc);
165
+ if (match)
166
+ state.statuses.set(match.sk, st);
167
+ }
147
168
  }
148
169
  }
170
+ detectMultipleWorktrees(state);
171
+ }
172
+ function detectMultipleWorktrees(state) {
173
+ const worktrees = new Set();
174
+ for (const st of state.statuses.values()) {
175
+ if (st.state === 'running' && st.worktree) {
176
+ worktrees.add(st.worktree);
177
+ }
178
+ }
179
+ state.showWorktreeColumn = worktrees.size > 1;
149
180
  }
150
181
  // --- Log Pattern Scanning ---
151
182
  function pollLogCounts(state) {
@@ -178,14 +209,18 @@ function pollLogCounts(state) {
178
209
  child.stderr.on('data', (d) => { output += d.toString(); });
179
210
  child.on('close', () => {
180
211
  const counts = new Map();
181
- for (const pattern of scanPatterns) {
212
+ for (const entry of scanPatterns) {
213
+ const group = Array.isArray(entry) ? entry : [entry];
214
+ const key = group[0];
182
215
  let count = 0;
183
- let idx = 0;
184
- while ((idx = output.indexOf(pattern, idx)) !== -1) {
185
- count++;
186
- idx += pattern.length;
216
+ for (const pattern of group) {
217
+ let idx = 0;
218
+ while ((idx = output.indexOf(pattern, idx)) !== -1) {
219
+ count++;
220
+ idx += pattern.length;
221
+ }
187
222
  }
188
- counts.set(pattern, count);
223
+ counts.set(key, count);
189
224
  }
190
225
  state.logCounts.set(sk, counts);
191
226
  remaining--;
@@ -341,9 +376,10 @@ function updateSelectedLogs(state) {
341
376
  if (state.bottomLogLines.has(sk))
342
377
  return;
343
378
  state.bottomLogLines.set(sk, { action: 'logs', service: entry.service, lines: [] });
379
+ const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
344
380
  moduleState.logFetchTimer = setTimeout(() => {
345
381
  moduleState.logFetchTimer = null;
346
- startBottomLogTail(state, sk, entry.file, entry.service);
382
+ startBottomLogTail(state, sk, effectiveFile, entry.service);
347
383
  }, 500);
348
384
  }
349
385
  function startBottomLogTail(state, sk, file, service) {
@@ -384,11 +420,12 @@ function doRebuild(state) {
384
420
  const sk = (0, state_1.statusKey)(entry.file, entry.service);
385
421
  if (state.rebuilding.has(sk))
386
422
  return;
423
+ const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
387
424
  if (state.bottomLogTails.has(sk)) {
388
425
  state.bottomLogTails.get(sk).kill('SIGTERM');
389
426
  state.bottomLogTails.delete(sk);
390
427
  }
391
- const child = (0, docker_1.rebuildService)(entry.file, entry.service, { noCache: state.noCache, noDeps: state.noDeps });
428
+ const child = (0, docker_1.rebuildService)(effectiveFile, entry.service, { noCache: state.noCache, noDeps: state.noDeps });
392
429
  state.rebuilding.set(sk, child);
393
430
  state.bottomLogLines.set(sk, { action: 'rebuilding', service: entry.service, lines: [] });
394
431
  let lineBuf = '';
@@ -432,7 +469,7 @@ function doRebuild(state) {
432
469
  if (state.logBuildKey !== sk)
433
470
  info.lines = [];
434
471
  }
435
- startBottomLogTail(state, sk, entry.file, entry.service);
472
+ startBottomLogTail(state, sk, effectiveFile, entry.service);
436
473
  if (state.mode === state_1.MODE.LIST)
437
474
  render(state);
438
475
  });
@@ -444,11 +481,12 @@ function doRestart(state) {
444
481
  const sk = (0, state_1.statusKey)(entry.file, entry.service);
445
482
  if (state.restarting.has(sk) || state.rebuilding.has(sk))
446
483
  return;
484
+ const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
447
485
  if (state.bottomLogTails.has(sk)) {
448
486
  state.bottomLogTails.get(sk).kill('SIGTERM');
449
487
  state.bottomLogTails.delete(sk);
450
488
  }
451
- const child = (0, docker_1.restartService)(entry.file, entry.service);
489
+ const child = (0, docker_1.restartService)(effectiveFile, entry.service);
452
490
  state.restarting.set(sk, child);
453
491
  state.bottomLogLines.set(sk, { action: 'restarting', service: entry.service, lines: [] });
454
492
  render(state);
@@ -469,7 +507,7 @@ function doRestart(state) {
469
507
  info.action = 'started';
470
508
  info.lines = [];
471
509
  }
472
- startBottomLogTail(state, sk, entry.file, entry.service);
510
+ startBottomLogTail(state, sk, effectiveFile, entry.service);
473
511
  if (state.mode === state_1.MODE.LIST)
474
512
  render(state);
475
513
  });
@@ -488,7 +526,8 @@ function doStop(state) {
488
526
  state.bottomLogTails.get(sk).kill('SIGTERM');
489
527
  state.bottomLogTails.delete(sk);
490
528
  }
491
- const child = (0, docker_1.stopService)(entry.file, entry.service);
529
+ const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
530
+ const child = (0, docker_1.stopService)(effectiveFile, entry.service);
492
531
  state.stopping.set(sk, child);
493
532
  state.bottomLogLines.set(sk, { action: 'stopping', service: entry.service, lines: [] });
494
533
  render(state);
@@ -517,7 +556,8 @@ function doStart(state) {
517
556
  const st = state.statuses.get(sk);
518
557
  if (st && st.state === 'running')
519
558
  return;
520
- const child = (0, docker_1.startService)(entry.file, entry.service);
559
+ const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
560
+ const child = (0, docker_1.startService)(effectiveFile, entry.service);
521
561
  state.starting.set(sk, child);
522
562
  state.bottomLogLines.set(sk, { action: 'starting', service: entry.service, lines: [] });
523
563
  render(state);
@@ -536,11 +576,164 @@ function doStart(state) {
536
576
  info.action = 'started';
537
577
  info.lines = [];
538
578
  }
539
- startBottomLogTail(state, sk, entry.file, entry.service);
579
+ startBottomLogTail(state, sk, effectiveFile, entry.service);
540
580
  if (state.mode === state_1.MODE.LIST)
541
581
  render(state);
542
582
  });
543
583
  }
584
+ // --- Worktree Switching ---
585
+ function mapComposeFileToWorktree(composeFile, targetWorktreePath) {
586
+ const resolved = path_1.default.resolve(composeFile);
587
+ const dir = path_1.default.dirname(resolved);
588
+ const gitRoot = (0, docker_1.getGitRoot)(dir);
589
+ if (!gitRoot)
590
+ return null;
591
+ const relPath = path_1.default.relative(gitRoot, resolved);
592
+ const newFile = path_1.default.join(targetWorktreePath, relPath);
593
+ try {
594
+ fs_1.default.accessSync(newFile);
595
+ return newFile;
596
+ }
597
+ catch {
598
+ return null;
599
+ }
600
+ }
601
+ function openWorktreePicker(state) {
602
+ const entry = (0, state_1.selectedEntry)(state);
603
+ if (!entry)
604
+ return;
605
+ const sk = (0, state_1.statusKey)(entry.file, entry.service);
606
+ if (state.rebuilding.has(sk) || state.restarting.has(sk) || state.stopping.has(sk) || state.starting.has(sk) || state.cascading.has(sk))
607
+ return;
608
+ const composeDir = path_1.default.dirname(path_1.default.resolve(entry.file));
609
+ const worktrees = (0, docker_1.listGitWorktrees)(composeDir);
610
+ if (worktrees.length <= 1) {
611
+ state.bottomLogLines.set(sk, { action: 'switch_failed', service: entry.service, lines: ['no other worktrees available — use `git worktree add` to create one'] });
612
+ state.showBottomLogs = true;
613
+ render(state);
614
+ return;
615
+ }
616
+ const gitRoot = (0, docker_1.getGitRoot)(composeDir);
617
+ state.worktreePickerEntries = worktrees;
618
+ state.worktreePickerActive = true;
619
+ state.worktreePickerCurrentPath = gitRoot;
620
+ // Pre-select first non-current worktree
621
+ const currentIdx = gitRoot ? worktrees.findIndex(w => w.path === gitRoot) : -1;
622
+ const firstOther = worktrees.findIndex((_, i) => i !== currentIdx);
623
+ state.worktreePickerCursor = firstOther >= 0 ? firstOther : 0;
624
+ state.showBottomLogs = true;
625
+ render(state);
626
+ }
627
+ function doWorktreeSwitch(state, targetWorktree) {
628
+ const entry = (0, state_1.selectedEntry)(state);
629
+ if (!entry)
630
+ return;
631
+ const service = entry.service;
632
+ const sk = (0, state_1.statusKey)(entry.file, service);
633
+ // Close picker
634
+ state.worktreePickerActive = false;
635
+ state.worktreePickerEntries = [];
636
+ state.worktreePickerCursor = 0;
637
+ // Compute new file from the original group file
638
+ const newFile = mapComposeFileToWorktree(entry.file, targetWorktree.path);
639
+ if (!newFile) {
640
+ state.bottomLogLines.set(sk, {
641
+ action: 'switch_failed', service,
642
+ lines: [`compose file not found in worktree "${targetWorktree.branch}" (${targetWorktree.path})`],
643
+ });
644
+ render(state);
645
+ return;
646
+ }
647
+ // If target is the same as current effective file, nothing to do
648
+ const currentEffective = (0, state_1.getEffectiveFile)(state, entry.file, service);
649
+ if (newFile === currentEffective) {
650
+ render(state);
651
+ return;
652
+ }
653
+ // Validate service exists in target compose file
654
+ if (!(0, docker_1.validateServiceInComposeFile)(newFile, service)) {
655
+ state.bottomLogLines.set(sk, {
656
+ action: 'switch_failed', service,
657
+ lines: [`service "${service}" not found in ${path_1.default.basename(newFile)} on branch "${targetWorktree.branch}"`],
658
+ });
659
+ render(state);
660
+ return;
661
+ }
662
+ // Show switching progress
663
+ state.bottomLogLines.set(sk, { action: 'switching', service, lines: [`switching to worktree "${targetWorktree.branch}"...`] });
664
+ render(state);
665
+ const performSwitch = () => {
666
+ // Store the worktree override (or remove if switching back to original)
667
+ if (newFile === entry.file) {
668
+ state.worktreeOverrides.delete(sk);
669
+ }
670
+ else {
671
+ state.worktreeOverrides.set(sk, newFile);
672
+ }
673
+ // Update bottomLogLines to show rebuild
674
+ state.bottomLogLines.set(sk, { action: 'switching', service, lines: [`rebuilding in worktree "${targetWorktree.branch}"...`] });
675
+ // Rebuild in new worktree
676
+ const child = (0, docker_1.rebuildService)(newFile, service, { noCache: state.noCache, noDeps: state.noDeps });
677
+ state.rebuilding.set(sk, child);
678
+ let lineBuf = '';
679
+ const onData = (data) => {
680
+ const info = state.bottomLogLines.get(sk);
681
+ if (!info)
682
+ return;
683
+ lineBuf += data.toString();
684
+ const parts = lineBuf.split(/\r?\n|\r/);
685
+ lineBuf = parts.pop();
686
+ const newLines = parts.filter(l => l.trim().length > 0).map(stripAnsi).filter(Boolean);
687
+ if (newLines.length === 0)
688
+ return;
689
+ info.lines.push(...newLines);
690
+ if (state.mode === state_1.MODE.LIST)
691
+ throttledRender(state);
692
+ };
693
+ child.stdout.on('data', onData);
694
+ child.stderr.on('data', onData);
695
+ render(state);
696
+ child.on('close', (code) => {
697
+ state.rebuilding.delete(sk);
698
+ state.containerStatsHistory.delete(sk);
699
+ state.containerStats.delete(sk);
700
+ pollStatuses(state);
701
+ const info = state.bottomLogLines.get(sk);
702
+ if (code !== 0 && code !== null) {
703
+ if (info)
704
+ info.action = 'build_failed';
705
+ if (state.mode === state_1.MODE.LIST)
706
+ render(state);
707
+ return;
708
+ }
709
+ if (info) {
710
+ info.action = 'started';
711
+ info.lines = [];
712
+ }
713
+ startBottomLogTail(state, sk, newFile, service);
714
+ if (state.mode === state_1.MODE.LIST)
715
+ render(state);
716
+ });
717
+ };
718
+ // If service is running, stop it first (using current effective file)
719
+ const st = state.statuses.get(sk);
720
+ if (st && st.state === 'running') {
721
+ if (state.bottomLogTails.has(sk)) {
722
+ state.bottomLogTails.get(sk).kill('SIGTERM');
723
+ state.bottomLogTails.delete(sk);
724
+ }
725
+ const stopChild = (0, docker_1.stopService)(currentEffective, service);
726
+ state.stopping.set(sk, stopChild);
727
+ render(state);
728
+ stopChild.on('close', () => {
729
+ state.stopping.delete(sk);
730
+ performSwitch();
731
+ });
732
+ }
733
+ else {
734
+ performSwitch();
735
+ }
736
+ }
544
737
  // --- Watch ---
545
738
  function doWatch(state) {
546
739
  const entry = (0, state_1.selectedEntry)(state);
@@ -568,7 +761,8 @@ function doWatch(state) {
568
761
  render(state);
569
762
  return;
570
763
  }
571
- const child = (0, docker_1.watchService)(entry.file, entry.service);
764
+ const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
765
+ const child = (0, docker_1.watchService)(effectiveFile, entry.service);
572
766
  state.watching.set(sk, child);
573
767
  state.bottomLogLines.set(sk, { action: 'watching', service: entry.service, lines: [] });
574
768
  state.showBottomLogs = true;
@@ -666,9 +860,21 @@ function doCascadeRebuild(state) {
666
860
  const sk = (0, state_1.statusKey)(entry.file, entry.service);
667
861
  if (state.rebuilding.has(sk) || state.cascading.has(sk))
668
862
  return;
669
- const graph = state.depGraphs.get(entry.file);
863
+ const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
864
+ let graph = state.depGraphs.get(effectiveFile);
865
+ if (!graph) {
866
+ // Try to parse dep graph for the effective file (may differ from original)
867
+ try {
868
+ graph = (0, docker_1.parseDependencyGraph)(effectiveFile);
869
+ state.depGraphs.set(effectiveFile, graph);
870
+ }
871
+ catch {
872
+ // No graph available, fall back to regular rebuild
873
+ doRebuild(state);
874
+ return;
875
+ }
876
+ }
670
877
  if (!graph) {
671
- // No graph available, fall back to regular rebuild
672
878
  doRebuild(state);
673
879
  return;
674
880
  }
@@ -687,7 +893,7 @@ function doCascadeRebuild(state) {
687
893
  state.cascading.set(sk, cascade);
688
894
  state.bottomLogLines.set(sk, { action: 'cascading', service: entry.service, lines: [] });
689
895
  state.showBottomLogs = true;
690
- executeCascadeStep(state, entry.file, sk, cascade);
896
+ executeCascadeStep(state, effectiveFile, sk, cascade);
691
897
  render(state);
692
898
  }
693
899
  function executeCascadeStep(state, file, sk, cascade) {
@@ -947,7 +1153,8 @@ function enterLogs(state) {
947
1153
  }
948
1154
  else {
949
1155
  state.logBuildKey = null;
950
- const child = (0, docker_1.tailLogs)(entry.file, entry.service, 200);
1156
+ const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
1157
+ const child = (0, docker_1.tailLogs)(effectiveFile, entry.service, 200);
951
1158
  state.logChild = child;
952
1159
  let lineBuf = '';
953
1160
  const onData = (data) => {
@@ -1013,7 +1220,8 @@ function loadMoreLogHistory(state) {
1013
1220
  nextTail = 'all';
1014
1221
  state.logHistoryLoading = true;
1015
1222
  const snapshotLen = state.logLines.length;
1016
- const child = (0, docker_1.fetchServiceLogs)(entry.file, entry.service, nextTail);
1223
+ const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
1224
+ const child = (0, docker_1.fetchServiceLogs)(effectiveFile, entry.service, nextTail);
1017
1225
  state.logHistoryChild = child;
1018
1226
  let output = '';
1019
1227
  child.stdout.on('data', (d) => { output += d.toString(); });
@@ -1110,7 +1318,8 @@ function executeBottomSearch(state) {
1110
1318
  state.bottomSearchLoading = true;
1111
1319
  state.bottomSearchTotalMatches = 0;
1112
1320
  render(state);
1113
- const child = (0, docker_1.fetchServiceLogs)(entry.file, entry.service, 'all');
1321
+ const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
1322
+ const child = (0, docker_1.fetchServiceLogs)(effectiveFile, entry.service, 'all');
1114
1323
  state.bottomSearchChild = child;
1115
1324
  let output = '';
1116
1325
  child.stdout.on('data', (d) => { output += d.toString(); });
@@ -1338,6 +1547,34 @@ function handleKeypress(state, key) {
1338
1547
  }
1339
1548
  return;
1340
1549
  }
1550
+ // LIST mode - worktree picker
1551
+ if (state.worktreePickerActive) {
1552
+ if (key === '\x1b') {
1553
+ state.worktreePickerActive = false;
1554
+ state.worktreePickerEntries = [];
1555
+ state.worktreePickerCursor = 0;
1556
+ state.worktreePickerCurrentPath = null;
1557
+ render(state);
1558
+ }
1559
+ else if (key === '\r') {
1560
+ const target = state.worktreePickerEntries[state.worktreePickerCursor];
1561
+ if (target)
1562
+ doWorktreeSwitch(state, target);
1563
+ }
1564
+ else if (key === 'j' || key === '\x1b[B') {
1565
+ state.worktreePickerCursor = Math.min(state.worktreePickerEntries.length - 1, state.worktreePickerCursor + 1);
1566
+ render(state);
1567
+ }
1568
+ else if (key === 'k' || key === '\x1b[A') {
1569
+ state.worktreePickerCursor = Math.max(0, state.worktreePickerCursor - 1);
1570
+ render(state);
1571
+ }
1572
+ else if (key === 'G') {
1573
+ state.worktreePickerCursor = state.worktreePickerEntries.length - 1;
1574
+ render(state);
1575
+ }
1576
+ return;
1577
+ }
1341
1578
  // LIST mode - inline exec input
1342
1579
  if (state.execActive) {
1343
1580
  if (key === '\x1b') {
@@ -1483,6 +1720,9 @@ function handleKeypress(state, key) {
1483
1720
  state.showBottomLogs = !state.showBottomLogs;
1484
1721
  render(state);
1485
1722
  break;
1723
+ case 't':
1724
+ openWorktreePicker(state);
1725
+ break;
1486
1726
  case 'q':
1487
1727
  cleanup(state);
1488
1728
  process.exit(0);
@@ -1536,7 +1776,7 @@ function createInputHandler(state) {
1536
1776
  }
1537
1777
  const ch = buf[0];
1538
1778
  buf = buf.slice(1);
1539
- if (state.logSearchActive || state.bottomSearchActive || state.mode === state_1.MODE.EXEC || state.execActive) {
1779
+ if (state.logSearchActive || state.bottomSearchActive || state.worktreePickerActive || state.mode === state_1.MODE.EXEC || state.execActive) {
1540
1780
  handleKeypress(state, ch);
1541
1781
  continue;
1542
1782
  }
@@ -1636,7 +1876,7 @@ function cleanup(state) {
1636
1876
  if (state.statsTimer) {
1637
1877
  clearInterval(state.statsTimer);
1638
1878
  }
1639
- process.stdout.write('\x1b[r' + (0, renderer_1.showCursor)() + '\x1b[0m');
1879
+ process.stdout.write('\x1b[r' + (0, renderer_1.showCursor)() + '\x1b[0m\x1b[?1049l');
1640
1880
  }
1641
1881
  // Expose for testing
1642
1882
  function _getModuleState() {
@@ -1647,6 +1887,8 @@ function _setModuleState(ms) {
1647
1887
  }
1648
1888
  // --- Main ---
1649
1889
  function main() {
1890
+ // Enter alternate screen buffer so pre-launch output (e.g. npx install) is hidden
1891
+ process.stdout.write('\x1b[?1049h');
1650
1892
  const config = loadConfig();
1651
1893
  const state = (0, state_1.createState)(config);
1652
1894
  state.groups = discoverServices(config);