recomposable 1.1.2 → 1.1.4

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/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,18 +19,25 @@ 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;
24
28
  exports.enterExecInline = enterExecInline;
25
29
  exports.enterExec = enterExec;
26
30
  exports.exitExec = exitExec;
31
+ exports.shellEscape = shellEscape;
27
32
  exports.runExecCommand = runExecCommand;
28
33
  exports.enterLogs = enterLogs;
29
34
  exports.exitLogs = exitLogs;
35
+ exports.loadMoreLogHistory = loadMoreLogHistory;
30
36
  exports.executeLogSearch = executeLogSearch;
31
37
  exports.jumpToNextMatch = jumpToNextMatch;
32
38
  exports.jumpToPrevMatch = jumpToPrevMatch;
39
+ exports.executeBottomSearch = executeBottomSearch;
40
+ exports.clearBottomSearch = clearBottomSearch;
33
41
  exports.handleKeypress = handleKeypress;
34
42
  exports.createInputHandler = createInputHandler;
35
43
  exports.cleanup = cleanup;
@@ -56,7 +64,7 @@ function loadConfig() {
56
64
  composeFiles: [],
57
65
  pollInterval: 3000,
58
66
  logTailLines: 100,
59
- logScanPatterns: ['WRN]', 'ERR]'],
67
+ logScanPatterns: [['WRN]', 'WARNING'], ['ERR]', 'ERROR']],
60
68
  logScanLines: 1000,
61
69
  logScanInterval: 10000,
62
70
  statsInterval: 5000,
@@ -69,7 +77,33 @@ function loadConfig() {
69
77
  };
70
78
  const configPath = path_1.default.join(process.cwd(), 'recomposable.json');
71
79
  if (fs_1.default.existsSync(configPath)) {
72
- Object.assign(defaults, JSON.parse(fs_1.default.readFileSync(configPath, 'utf8')));
80
+ const raw = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
81
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
82
+ if (Array.isArray(raw.composeFiles) && raw.composeFiles.every((f) => typeof f === 'string')) {
83
+ defaults.composeFiles = raw.composeFiles;
84
+ }
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')))) {
86
+ defaults.logScanPatterns = raw.logScanPatterns;
87
+ }
88
+ const numericFields = [
89
+ { key: 'pollInterval', min: 500, max: 300000 },
90
+ { key: 'logTailLines', min: 1, max: 50000 },
91
+ { key: 'logScanLines', min: 1, max: 50000 },
92
+ { key: 'logScanInterval', min: 1000, max: 600000 },
93
+ { key: 'statsInterval', min: 1000, max: 600000 },
94
+ { key: 'statsBufferSize', min: 1, max: 100 },
95
+ { key: 'bottomLogCount', min: 1, max: 200 },
96
+ { key: 'cpuWarnThreshold', min: 0, max: 10000 },
97
+ { key: 'cpuDangerThreshold', min: 0, max: 10000 },
98
+ { key: 'memWarnThreshold', min: 0, max: 1048576 },
99
+ { key: 'memDangerThreshold', min: 0, max: 1048576 },
100
+ ];
101
+ for (const { key, min, max } of numericFields) {
102
+ if (typeof raw[key] === 'number' && isFinite(raw[key]) && raw[key] >= min && raw[key] <= max) {
103
+ defaults[key] = raw[key];
104
+ }
105
+ }
106
+ }
73
107
  }
74
108
  const args = process.argv.slice(2);
75
109
  const cliFiles = [];
@@ -108,14 +142,41 @@ function discoverServices(config) {
108
142
  }
109
143
  // --- Status Polling ---
110
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();
111
147
  for (const group of state.groups) {
112
148
  if (group.error)
113
149
  continue;
114
- 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));
115
161
  for (const [svc, st] of statuses) {
116
- 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
+ }
117
168
  }
118
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;
119
180
  }
120
181
  // --- Log Pattern Scanning ---
121
182
  function pollLogCounts(state) {
@@ -148,14 +209,18 @@ function pollLogCounts(state) {
148
209
  child.stderr.on('data', (d) => { output += d.toString(); });
149
210
  child.on('close', () => {
150
211
  const counts = new Map();
151
- for (const pattern of scanPatterns) {
212
+ for (const entry of scanPatterns) {
213
+ const group = Array.isArray(entry) ? entry : [entry];
214
+ const key = group[0];
152
215
  let count = 0;
153
- let idx = 0;
154
- while ((idx = output.indexOf(pattern, idx)) !== -1) {
155
- count++;
156
- 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
+ }
157
222
  }
158
- counts.set(pattern, count);
223
+ counts.set(key, count);
159
224
  }
160
225
  state.logCounts.set(sk, counts);
161
226
  remaining--;
@@ -244,20 +309,26 @@ function pollContainerStats(state) {
244
309
  }
245
310
  // --- Rendering ---
246
311
  function render(state) {
247
- let output = (0, renderer_1.clearScreen)();
312
+ let view = '';
248
313
  if (state.mode === state_1.MODE.LIST) {
249
- output += (0, renderer_1.renderListView)(state);
314
+ view = (0, renderer_1.renderListView)(state);
250
315
  }
251
316
  else if (state.mode === state_1.MODE.LOGS) {
252
- output += (0, renderer_1.renderLogView)(state);
317
+ view = (0, renderer_1.renderLogView)(state);
253
318
  }
254
319
  else if (state.mode === state_1.MODE.EXEC) {
255
- output += (0, renderer_1.renderExecView)(state);
320
+ view = (0, renderer_1.renderExecView)(state);
256
321
  }
257
- process.stdout.write(output);
322
+ // View functions already embed CLEAR_EOL per line; just clear below last line
323
+ process.stdout.write((0, renderer_1.clearScreen)() + view + renderer_1.CLEAR_EOL + renderer_1.CLEAR_EOS);
258
324
  }
259
325
  function stripAnsi(str) {
260
- return str.replace(/\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[^[\]]/g, '');
326
+ return str.replace(
327
+ // CSI sequences: \x1b[ ... letter
328
+ // OSC sequences: \x1b] ... BEL or \x1b] ... ST
329
+ // DCS/APC/PM/SOS sequences: \x1bP/\x1b_/\x1b^/\x1bX ... ST (where ST = \x1b\\)
330
+ // Two-byte escape sequences: \x1b + any char
331
+ /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[P_^X][^\x1b]*(?:\x1b\\|\x07)|\x1b[^[\]P_^X]/g, '');
261
332
  }
262
333
  function throttledRender(state) {
263
334
  const now = Date.now();
@@ -284,6 +355,7 @@ function updateSelectedLogs(state) {
284
355
  return;
285
356
  state.bottomSearchQuery = '';
286
357
  state.bottomSearchActive = false;
358
+ clearBottomSearch(state);
287
359
  if (moduleState.logFetchTimer) {
288
360
  clearTimeout(moduleState.logFetchTimer);
289
361
  moduleState.logFetchTimer = null;
@@ -304,9 +376,10 @@ function updateSelectedLogs(state) {
304
376
  if (state.bottomLogLines.has(sk))
305
377
  return;
306
378
  state.bottomLogLines.set(sk, { action: 'logs', service: entry.service, lines: [] });
379
+ const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
307
380
  moduleState.logFetchTimer = setTimeout(() => {
308
381
  moduleState.logFetchTimer = null;
309
- startBottomLogTail(state, sk, entry.file, entry.service);
382
+ startBottomLogTail(state, sk, effectiveFile, entry.service);
310
383
  }, 500);
311
384
  }
312
385
  function startBottomLogTail(state, sk, file, service) {
@@ -347,11 +420,12 @@ function doRebuild(state) {
347
420
  const sk = (0, state_1.statusKey)(entry.file, entry.service);
348
421
  if (state.rebuilding.has(sk))
349
422
  return;
423
+ const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
350
424
  if (state.bottomLogTails.has(sk)) {
351
425
  state.bottomLogTails.get(sk).kill('SIGTERM');
352
426
  state.bottomLogTails.delete(sk);
353
427
  }
354
- const child = (0, docker_1.rebuildService)(entry.file, entry.service, { noCache: state.noCache });
428
+ const child = (0, docker_1.rebuildService)(effectiveFile, entry.service, { noCache: state.noCache, noDeps: state.noDeps });
355
429
  state.rebuilding.set(sk, child);
356
430
  state.bottomLogLines.set(sk, { action: 'rebuilding', service: entry.service, lines: [] });
357
431
  let lineBuf = '';
@@ -366,26 +440,36 @@ function doRebuild(state) {
366
440
  if (newLines.length === 0)
367
441
  return;
368
442
  info.lines.push(...newLines);
369
- const maxLines = state.config.bottomLogCount || 10;
370
- if (info.lines.length > maxLines)
371
- info.lines = info.lines.slice(-maxLines);
443
+ if (state.mode === state_1.MODE.LOGS && state.logBuildKey === sk) {
444
+ state.logLines.push(...newLines);
445
+ if (state.logAutoScroll)
446
+ throttledRender(state);
447
+ }
372
448
  if (state.mode === state_1.MODE.LIST)
373
449
  throttledRender(state);
374
450
  };
375
451
  child.stdout.on('data', onData);
376
452
  child.stderr.on('data', onData);
377
453
  render(state);
378
- child.on('close', () => {
454
+ child.on('close', (code) => {
379
455
  state.rebuilding.delete(sk);
380
456
  state.containerStatsHistory.delete(sk);
381
457
  state.containerStats.delete(sk);
382
458
  pollStatuses(state);
383
459
  const info = state.bottomLogLines.get(sk);
460
+ if (code !== 0 && code !== null) {
461
+ if (info)
462
+ info.action = 'build_failed';
463
+ if (state.mode === state_1.MODE.LIST)
464
+ render(state);
465
+ return;
466
+ }
384
467
  if (info) {
385
468
  info.action = 'started';
386
- info.lines = [];
469
+ if (state.logBuildKey !== sk)
470
+ info.lines = [];
387
471
  }
388
- startBottomLogTail(state, sk, entry.file, entry.service);
472
+ startBottomLogTail(state, sk, effectiveFile, entry.service);
389
473
  if (state.mode === state_1.MODE.LIST)
390
474
  render(state);
391
475
  });
@@ -397,25 +481,33 @@ function doRestart(state) {
397
481
  const sk = (0, state_1.statusKey)(entry.file, entry.service);
398
482
  if (state.restarting.has(sk) || state.rebuilding.has(sk))
399
483
  return;
484
+ const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
400
485
  if (state.bottomLogTails.has(sk)) {
401
486
  state.bottomLogTails.get(sk).kill('SIGTERM');
402
487
  state.bottomLogTails.delete(sk);
403
488
  }
404
- const child = (0, docker_1.restartService)(entry.file, entry.service);
489
+ const child = (0, docker_1.restartService)(effectiveFile, entry.service);
405
490
  state.restarting.set(sk, child);
406
491
  state.bottomLogLines.set(sk, { action: 'restarting', service: entry.service, lines: [] });
407
492
  render(state);
408
- child.on('close', () => {
493
+ child.on('close', (code) => {
409
494
  state.restarting.delete(sk);
410
495
  state.containerStatsHistory.delete(sk);
411
496
  state.containerStats.delete(sk);
412
497
  pollStatuses(state);
413
498
  const info = state.bottomLogLines.get(sk);
499
+ if (code !== 0 && code !== null) {
500
+ if (info)
501
+ info.action = 'restart_failed';
502
+ if (state.mode === state_1.MODE.LIST)
503
+ render(state);
504
+ return;
505
+ }
414
506
  if (info) {
415
507
  info.action = 'started';
416
508
  info.lines = [];
417
509
  }
418
- startBottomLogTail(state, sk, entry.file, entry.service);
510
+ startBottomLogTail(state, sk, effectiveFile, entry.service);
419
511
  if (state.mode === state_1.MODE.LIST)
420
512
  render(state);
421
513
  });
@@ -434,13 +526,21 @@ function doStop(state) {
434
526
  state.bottomLogTails.get(sk).kill('SIGTERM');
435
527
  state.bottomLogTails.delete(sk);
436
528
  }
437
- 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);
438
531
  state.stopping.set(sk, child);
439
532
  state.bottomLogLines.set(sk, { action: 'stopping', service: entry.service, lines: [] });
440
533
  render(state);
441
- child.on('close', () => {
534
+ child.on('close', (code) => {
442
535
  state.stopping.delete(sk);
443
- state.bottomLogLines.delete(sk);
536
+ if (code !== 0 && code !== null) {
537
+ const info = state.bottomLogLines.get(sk);
538
+ if (info)
539
+ info.action = 'stop_failed';
540
+ }
541
+ else {
542
+ state.bottomLogLines.delete(sk);
543
+ }
444
544
  pollStatuses(state);
445
545
  if (state.mode === state_1.MODE.LIST)
446
546
  render(state);
@@ -456,23 +556,184 @@ function doStart(state) {
456
556
  const st = state.statuses.get(sk);
457
557
  if (st && st.state === 'running')
458
558
  return;
459
- 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);
460
561
  state.starting.set(sk, child);
461
562
  state.bottomLogLines.set(sk, { action: 'starting', service: entry.service, lines: [] });
462
563
  render(state);
463
- child.on('close', () => {
564
+ child.on('close', (code) => {
464
565
  state.starting.delete(sk);
465
566
  pollStatuses(state);
466
567
  const info = state.bottomLogLines.get(sk);
568
+ if (code !== 0 && code !== null) {
569
+ if (info)
570
+ info.action = 'start_failed';
571
+ if (state.mode === state_1.MODE.LIST)
572
+ render(state);
573
+ return;
574
+ }
467
575
  if (info) {
468
576
  info.action = 'started';
469
577
  info.lines = [];
470
578
  }
471
- startBottomLogTail(state, sk, entry.file, entry.service);
579
+ startBottomLogTail(state, sk, effectiveFile, entry.service);
472
580
  if (state.mode === state_1.MODE.LIST)
473
581
  render(state);
474
582
  });
475
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
+ }
476
737
  // --- Watch ---
477
738
  function doWatch(state) {
478
739
  const entry = (0, state_1.selectedEntry)(state);
@@ -500,7 +761,8 @@ function doWatch(state) {
500
761
  render(state);
501
762
  return;
502
763
  }
503
- 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);
504
766
  state.watching.set(sk, child);
505
767
  state.bottomLogLines.set(sk, { action: 'watching', service: entry.service, lines: [] });
506
768
  state.showBottomLogs = true;
@@ -598,9 +860,21 @@ function doCascadeRebuild(state) {
598
860
  const sk = (0, state_1.statusKey)(entry.file, entry.service);
599
861
  if (state.rebuilding.has(sk) || state.cascading.has(sk))
600
862
  return;
601
- 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
+ }
602
877
  if (!graph) {
603
- // No graph available, fall back to regular rebuild
604
878
  doRebuild(state);
605
879
  return;
606
880
  }
@@ -619,7 +893,7 @@ function doCascadeRebuild(state) {
619
893
  state.cascading.set(sk, cascade);
620
894
  state.bottomLogLines.set(sk, { action: 'cascading', service: entry.service, lines: [] });
621
895
  state.showBottomLogs = true;
622
- executeCascadeStep(state, entry.file, sk, cascade);
896
+ executeCascadeStep(state, effectiveFile, sk, cascade);
623
897
  render(state);
624
898
  }
625
899
  function executeCascadeStep(state, file, sk, cascade) {
@@ -633,10 +907,9 @@ function executeCascadeStep(state, file, sk, cascade) {
633
907
  return;
634
908
  }
635
909
  step.status = 'in_progress';
636
- const maxLines = state.config.bottomLogCount || 10;
637
910
  let child;
638
911
  if (step.action === 'rebuild') {
639
- child = (0, docker_1.rebuildService)(file, step.service, { noCache: state.noCache });
912
+ child = (0, docker_1.rebuildService)(file, step.service, { noCache: state.noCache, noDeps: state.noDeps });
640
913
  }
641
914
  else {
642
915
  child = (0, docker_1.restartService)(file, step.service);
@@ -654,8 +927,11 @@ function executeCascadeStep(state, file, sk, cascade) {
654
927
  if (newLines.length === 0)
655
928
  return;
656
929
  info.lines.push(...newLines);
657
- if (info.lines.length > maxLines)
658
- info.lines = info.lines.slice(-maxLines);
930
+ if (state.mode === state_1.MODE.LOGS && state.logBuildKey === sk) {
931
+ state.logLines.push(...newLines);
932
+ if (state.logAutoScroll)
933
+ throttledRender(state);
934
+ }
659
935
  if (state.mode === state_1.MODE.LIST)
660
936
  throttledRender(state);
661
937
  };
@@ -753,13 +1029,18 @@ function isCdCommand(cmd) {
753
1029
  return null;
754
1030
  return match[2] ? match[2].trim() : '';
755
1031
  }
1032
+ function shellEscape(str) {
1033
+ return "'" + str.replace(/'/g, "'\\''") + "'";
1034
+ }
756
1035
  function runExecCommand(state) {
757
1036
  const cmd = state.execInput.trim();
758
1037
  if (!cmd || !state.execContainerId)
759
1038
  return;
760
- // Add to history
1039
+ // Add to history (capped at 1000 entries)
761
1040
  if (state.execHistory.length === 0 || state.execHistory[state.execHistory.length - 1] !== cmd) {
762
1041
  state.execHistory.push(cmd);
1042
+ if (state.execHistory.length > 1000)
1043
+ state.execHistory.shift();
763
1044
  }
764
1045
  state.execHistoryIdx = -1;
765
1046
  state.execInput = '';
@@ -772,7 +1053,7 @@ function runExecCommand(state) {
772
1053
  // Handle cd commands — resolve new working directory
773
1054
  const cdTarget = isCdCommand(cmd);
774
1055
  if (cdTarget !== null) {
775
- const resolveCmd = cdTarget ? `cd ${cdTarget} && pwd` : 'cd && pwd';
1056
+ const resolveCmd = cdTarget ? `cd ${shellEscape(cdTarget)} && pwd` : 'cd && pwd';
776
1057
  const child = (0, docker_1.execInContainer)(state.execContainerId, resolveCmd, state.execCwd || undefined);
777
1058
  state.execChild = child;
778
1059
  let stdout = '';
@@ -785,8 +1066,9 @@ function runExecCommand(state) {
785
1066
  if (code === 0) {
786
1067
  const lines = stdout.trim().split('\n');
787
1068
  const newCwd = lines[lines.length - 1].trim();
788
- if (newCwd)
1069
+ if (newCwd && newCwd.startsWith('/') && newCwd.length < 4096 && !/[\x00-\x1f]/.test(newCwd)) {
789
1070
  state.execCwd = newCwd;
1071
+ }
790
1072
  }
791
1073
  else {
792
1074
  const errLines = stderr.trim().split('\n').filter(Boolean);
@@ -841,44 +1123,66 @@ function enterLogs(state) {
841
1123
  clearTimeout(moduleState.logFetchTimer);
842
1124
  moduleState.logFetchTimer = null;
843
1125
  }
1126
+ // Carry over bottom panel search query to full log search
1127
+ const carryQuery = state.bottomSearchQuery || '';
1128
+ clearBottomSearch(state);
1129
+ const sk = (0, state_1.statusKey)(entry.file, entry.service);
1130
+ const info = state.bottomLogLines.get(sk);
1131
+ const isBuilding = state.rebuilding.has(sk) || state.cascading.has(sk);
1132
+ const isBuildFailed = info && info.action === 'build_failed';
844
1133
  state.mode = state_1.MODE.LOGS;
845
1134
  state.logLines = [];
846
1135
  state.logScrollOffset = 0;
847
1136
  state.logAutoScroll = true;
848
- state.logSearchQuery = '';
1137
+ state.logSearchQuery = carryQuery;
849
1138
  state.logSearchActive = false;
850
1139
  state.logSearchMatches = [];
851
1140
  state.logSearchMatchIdx = -1;
852
- const child = (0, docker_1.tailLogs)(entry.file, entry.service, state.config.logTailLines);
853
- state.logChild = child;
854
- let lineBuf = '';
855
- const onData = (data) => {
856
- lineBuf += data.toString();
857
- const parts = lineBuf.split(/\r?\n|\r/);
858
- lineBuf = parts.pop();
859
- if (parts.length === 0)
860
- return;
861
- for (const line of parts) {
862
- state.logLines.push(stripAnsi(line));
1141
+ state.logFetchedTailCount = 200;
1142
+ state.logHistoryLoaded = false;
1143
+ state.logHistoryLoading = false;
1144
+ state.logSearchPending = !!carryQuery;
1145
+ state.logHistoryChild = null;
1146
+ if (isBuilding || isBuildFailed) {
1147
+ // Show build output instead of runtime logs
1148
+ state.logBuildKey = sk;
1149
+ if (info) {
1150
+ state.logLines = [...info.lines];
863
1151
  }
864
- if (state.logLines.length > 10000) {
865
- const excess = state.logLines.length - 10000;
866
- state.logLines.splice(0, excess);
867
- if (!state.logAutoScroll) {
868
- state.logScrollOffset = Math.max(0, state.logScrollOffset - excess);
1152
+ state.logHistoryLoaded = true;
1153
+ }
1154
+ else {
1155
+ state.logBuildKey = null;
1156
+ const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
1157
+ const child = (0, docker_1.tailLogs)(effectiveFile, entry.service, 200);
1158
+ state.logChild = child;
1159
+ let lineBuf = '';
1160
+ const onData = (data) => {
1161
+ lineBuf += data.toString();
1162
+ const parts = lineBuf.split(/\r?\n|\r/);
1163
+ lineBuf = parts.pop();
1164
+ if (parts.length === 0)
1165
+ return;
1166
+ for (const line of parts) {
1167
+ state.logLines.push(stripAnsi(line));
869
1168
  }
1169
+ if (state.logAutoScroll) {
1170
+ throttledRender(state);
1171
+ }
1172
+ };
1173
+ child.stdout.on('data', onData);
1174
+ child.stderr.on('data', onData);
1175
+ child.on('close', () => {
1176
+ if (state.logChild === child) {
1177
+ state.logChild = null;
1178
+ }
1179
+ });
1180
+ // If carrying a search query from bottom panel, load full history to search
1181
+ if (carryQuery) {
1182
+ state.logFetchedTailCount = 5000;
1183
+ loadMoreLogHistory(state);
870
1184
  }
871
- if (state.logAutoScroll) {
872
- throttledRender(state);
873
- }
874
- };
875
- child.stdout.on('data', onData);
876
- child.stderr.on('data', onData);
877
- child.on('close', () => {
878
- if (state.logChild === child) {
879
- state.logChild = null;
880
- }
881
- });
1185
+ }
882
1186
  render(state);
883
1187
  }
884
1188
  function exitLogs(state) {
@@ -886,11 +1190,73 @@ function exitLogs(state) {
886
1190
  state.logChild.kill('SIGTERM');
887
1191
  state.logChild = null;
888
1192
  }
1193
+ if (state.logHistoryChild) {
1194
+ state.logHistoryChild.kill('SIGTERM');
1195
+ state.logHistoryChild = null;
1196
+ }
889
1197
  state.logLines = [];
1198
+ state.logBuildKey = null;
1199
+ state.logHistoryLoaded = false;
1200
+ state.logHistoryLoading = false;
1201
+ state.logSearchPending = false;
890
1202
  state.mode = state_1.MODE.LIST;
891
1203
  pollStatuses(state);
892
1204
  render(state);
893
1205
  }
1206
+ // --- Log History Loading ---
1207
+ function loadMoreLogHistory(state) {
1208
+ if (state.logHistoryLoaded || state.logHistoryLoading)
1209
+ return;
1210
+ const entry = (0, state_1.selectedEntry)(state);
1211
+ if (!entry)
1212
+ return;
1213
+ // Escalate: 200 → 1000 → 5000 → all
1214
+ let nextTail;
1215
+ if (state.logFetchedTailCount < 1000)
1216
+ nextTail = 1000;
1217
+ else if (state.logFetchedTailCount < 5000)
1218
+ nextTail = 5000;
1219
+ else
1220
+ nextTail = 'all';
1221
+ state.logHistoryLoading = true;
1222
+ const snapshotLen = state.logLines.length;
1223
+ const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
1224
+ const child = (0, docker_1.fetchServiceLogs)(effectiveFile, entry.service, nextTail);
1225
+ state.logHistoryChild = child;
1226
+ let output = '';
1227
+ child.stdout.on('data', (d) => { output += d.toString(); });
1228
+ child.stderr.on('data', (d) => { output += d.toString(); });
1229
+ child.on('close', () => {
1230
+ if (state.logHistoryChild === child) {
1231
+ state.logHistoryChild = null;
1232
+ }
1233
+ state.logHistoryLoading = false;
1234
+ const fetchedLines = output.split(/\r?\n|\r/).filter(l => l.length > 0).map(stripAnsi).filter(Boolean);
1235
+ if (fetchedLines.length <= snapshotLen) {
1236
+ // No more history available
1237
+ state.logHistoryLoaded = true;
1238
+ }
1239
+ else {
1240
+ // Merge: fetched history + any new lines that arrived during the fetch
1241
+ const newLiveLines = state.logLines.slice(snapshotLen);
1242
+ const oldOffset = state.logScrollOffset;
1243
+ const added = fetchedLines.length - snapshotLen;
1244
+ state.logLines = [...fetchedLines, ...newLiveLines];
1245
+ // Adjust scroll offset to maintain visual position
1246
+ if (!state.logAutoScroll) {
1247
+ state.logScrollOffset = oldOffset + added;
1248
+ }
1249
+ state.logFetchedTailCount = nextTail === 'all' ? Infinity : nextTail;
1250
+ if (nextTail === 'all')
1251
+ state.logHistoryLoaded = true;
1252
+ }
1253
+ if (state.logSearchPending) {
1254
+ state.logSearchPending = false;
1255
+ executeLogSearch(state);
1256
+ }
1257
+ render(state);
1258
+ });
1259
+ }
894
1260
  // --- Log Search ---
895
1261
  function executeLogSearch(state) {
896
1262
  const query = state.logSearchQuery;
@@ -914,7 +1280,8 @@ function scrollToLogLine(state, lineIdx) {
914
1280
  const headerHeight = 9;
915
1281
  const availableRows = Math.max(1, rows - headerHeight);
916
1282
  const totalLines = state.logLines.length;
917
- state.logScrollOffset = Math.max(0, totalLines - lineIdx - Math.floor(availableRows / 2));
1283
+ const maxOffset = Math.max(0, totalLines - availableRows);
1284
+ state.logScrollOffset = Math.min(maxOffset, Math.max(0, totalLines - lineIdx - Math.floor(availableRows / 2)));
918
1285
  state.logAutoScroll = state.logScrollOffset === 0;
919
1286
  render(state);
920
1287
  }
@@ -930,6 +1297,79 @@ function jumpToPrevMatch(state) {
930
1297
  state.logSearchMatchIdx = (state.logSearchMatchIdx - 1 + state.logSearchMatches.length) % state.logSearchMatches.length;
931
1298
  scrollToLogLine(state, state.logSearchMatches[state.logSearchMatchIdx]);
932
1299
  }
1300
+ // --- Bottom Panel Search ---
1301
+ function executeBottomSearch(state) {
1302
+ const entry = (0, state_1.selectedEntry)(state);
1303
+ if (!entry || !state.bottomSearchQuery)
1304
+ return;
1305
+ const sk = (0, state_1.statusKey)(entry.file, entry.service);
1306
+ const info = state.bottomLogLines.get(sk);
1307
+ if (!info)
1308
+ return;
1309
+ // Save current tail lines so we can restore them later
1310
+ if (!state.bottomSearchSavedLines.has(sk)) {
1311
+ state.bottomSearchSavedLines.set(sk, [...info.lines]);
1312
+ }
1313
+ // Kill any previous search fetch
1314
+ if (state.bottomSearchChild) {
1315
+ state.bottomSearchChild.kill('SIGTERM');
1316
+ state.bottomSearchChild = null;
1317
+ }
1318
+ state.bottomSearchLoading = true;
1319
+ state.bottomSearchTotalMatches = 0;
1320
+ render(state);
1321
+ const effectiveFile = (0, state_1.getEffectiveFile)(state, entry.file, entry.service);
1322
+ const child = (0, docker_1.fetchServiceLogs)(effectiveFile, entry.service, 'all');
1323
+ state.bottomSearchChild = child;
1324
+ let output = '';
1325
+ child.stdout.on('data', (d) => { output += d.toString(); });
1326
+ child.stderr.on('data', (d) => { output += d.toString(); });
1327
+ child.on('close', () => {
1328
+ if (state.bottomSearchChild !== child)
1329
+ return; // superseded
1330
+ state.bottomSearchChild = null;
1331
+ state.bottomSearchLoading = false;
1332
+ const query = state.bottomSearchQuery;
1333
+ if (!query) {
1334
+ clearBottomSearch(state);
1335
+ render(state);
1336
+ return;
1337
+ }
1338
+ const lowerQuery = query.toLowerCase();
1339
+ const allLines = output.split(/\r?\n|\r/).filter(l => l.trim().length > 0).map(stripAnsi).filter(Boolean);
1340
+ const matchingLines = [];
1341
+ for (const line of allLines) {
1342
+ if (line.toLowerCase().includes(lowerQuery)) {
1343
+ matchingLines.push(line);
1344
+ }
1345
+ }
1346
+ state.bottomSearchTotalMatches = matchingLines.length;
1347
+ // Show the last N matching lines in the bottom panel
1348
+ const maxLines = state.config.bottomLogCount || 10;
1349
+ const currentInfo = state.bottomLogLines.get(sk);
1350
+ if (currentInfo) {
1351
+ currentInfo.lines = matchingLines.slice(-maxLines);
1352
+ }
1353
+ if (state.mode === state_1.MODE.LIST)
1354
+ render(state);
1355
+ });
1356
+ }
1357
+ function clearBottomSearch(state) {
1358
+ if (state.bottomSearchChild) {
1359
+ state.bottomSearchChild.kill('SIGTERM');
1360
+ state.bottomSearchChild = null;
1361
+ }
1362
+ state.bottomSearchLoading = false;
1363
+ state.bottomSearchTotalMatches = 0;
1364
+ // Restore saved tail lines
1365
+ if (state.selectedLogKey && state.bottomSearchSavedLines.has(state.selectedLogKey)) {
1366
+ const info = state.bottomLogLines.get(state.selectedLogKey);
1367
+ if (info) {
1368
+ info.lines = state.bottomSearchSavedLines.get(state.selectedLogKey);
1369
+ }
1370
+ state.bottomSearchSavedLines.delete(state.selectedLogKey);
1371
+ }
1372
+ }
933
1373
  // --- Input Handling ---
934
1374
  function handleKeypress(state, key) {
935
1375
  if (key === '\x03' && state.mode !== state_1.MODE.EXEC && !state.execActive) {
@@ -1002,8 +1442,22 @@ function handleKeypress(state, key) {
1002
1442
  }
1003
1443
  else if (key === '\r') {
1004
1444
  state.logSearchActive = false;
1005
- executeLogSearch(state);
1006
- render(state);
1445
+ if (!state.logHistoryLoaded && !state.logHistoryLoading) {
1446
+ // Load all history, then search
1447
+ state.logSearchPending = true;
1448
+ state.logFetchedTailCount = 5000; // jump straight to 'all'
1449
+ loadMoreLogHistory(state);
1450
+ render(state);
1451
+ }
1452
+ else if (state.logHistoryLoading) {
1453
+ // Already loading — search will run when it finishes
1454
+ state.logSearchPending = true;
1455
+ render(state);
1456
+ }
1457
+ else {
1458
+ executeLogSearch(state);
1459
+ render(state);
1460
+ }
1007
1461
  }
1008
1462
  else if (key === '\x7f' || key === '\b') {
1009
1463
  state.logSearchQuery = state.logSearchQuery.slice(0, -1);
@@ -1017,12 +1471,31 @@ function handleKeypress(state, key) {
1017
1471
  }
1018
1472
  const rows = process.stdout.rows ?? 24;
1019
1473
  const pageSize = Math.max(1, Math.floor(rows / 2));
1020
- const maxOffset = Math.max(0, state.logLines.length - 1);
1474
+ const availableRows = Math.max(1, rows - 9);
1475
+ const maxOffset = Math.max(0, state.logLines.length - availableRows);
1476
+ // Trigger lazy history load when scrolled near the top
1477
+ const checkLoadMore = () => {
1478
+ const availableRows = Math.max(1, rows - 9);
1479
+ const linesFromTop = state.logLines.length - state.logScrollOffset - availableRows;
1480
+ if (linesFromTop < availableRows) {
1481
+ loadMoreLogHistory(state);
1482
+ }
1483
+ };
1021
1484
  switch (key) {
1022
1485
  case 'f':
1023
- case '\x1b':
1024
1486
  exitLogs(state);
1025
1487
  break;
1488
+ case '\x1b':
1489
+ if (state.logSearchQuery) {
1490
+ state.logSearchQuery = '';
1491
+ state.logSearchMatches = [];
1492
+ state.logSearchMatchIdx = -1;
1493
+ render(state);
1494
+ }
1495
+ else {
1496
+ exitLogs(state);
1497
+ }
1498
+ break;
1026
1499
  case 'q':
1027
1500
  cleanup(state);
1028
1501
  process.exit(0);
@@ -1031,6 +1504,7 @@ function handleKeypress(state, key) {
1031
1504
  case '\x1b[A':
1032
1505
  state.logAutoScroll = false;
1033
1506
  state.logScrollOffset = Math.min(maxOffset, state.logScrollOffset + 1);
1507
+ checkLoadMore();
1034
1508
  render(state);
1035
1509
  break;
1036
1510
  case 'j':
@@ -1050,6 +1524,7 @@ function handleKeypress(state, key) {
1050
1524
  case '\x15': // Ctrl+U
1051
1525
  state.logAutoScroll = false;
1052
1526
  state.logScrollOffset = Math.min(maxOffset, state.logScrollOffset + pageSize);
1527
+ checkLoadMore();
1053
1528
  render(state);
1054
1529
  break;
1055
1530
  case '\x04': // Ctrl+D
@@ -1072,6 +1547,34 @@ function handleKeypress(state, key) {
1072
1547
  }
1073
1548
  return;
1074
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
+ }
1075
1578
  // LIST mode - inline exec input
1076
1579
  if (state.execActive) {
1077
1580
  if (key === '\x1b') {
@@ -1135,10 +1638,14 @@ function handleKeypress(state, key) {
1135
1638
  if (key === '\x1b') {
1136
1639
  state.bottomSearchActive = false;
1137
1640
  state.bottomSearchQuery = '';
1641
+ clearBottomSearch(state);
1138
1642
  render(state);
1139
1643
  }
1140
1644
  else if (key === '\r') {
1141
1645
  state.bottomSearchActive = false;
1646
+ if (state.bottomSearchQuery) {
1647
+ executeBottomSearch(state);
1648
+ }
1142
1649
  render(state);
1143
1650
  }
1144
1651
  else if (key === '\x7f' || key === '\b') {
@@ -1201,6 +1708,10 @@ function handleKeypress(state, key) {
1201
1708
  state.noCache = !state.noCache;
1202
1709
  render(state);
1203
1710
  break;
1711
+ case 'o':
1712
+ state.noDeps = !state.noDeps;
1713
+ render(state);
1714
+ break;
1204
1715
  case 'f':
1205
1716
  case '\r':
1206
1717
  enterLogs(state);
@@ -1209,6 +1720,9 @@ function handleKeypress(state, key) {
1209
1720
  state.showBottomLogs = !state.showBottomLogs;
1210
1721
  render(state);
1211
1722
  break;
1723
+ case 't':
1724
+ openWorktreePicker(state);
1725
+ break;
1212
1726
  case 'q':
1213
1727
  cleanup(state);
1214
1728
  process.exit(0);
@@ -1262,7 +1776,7 @@ function createInputHandler(state) {
1262
1776
  }
1263
1777
  const ch = buf[0];
1264
1778
  buf = buf.slice(1);
1265
- 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) {
1266
1780
  handleKeypress(state, ch);
1267
1781
  continue;
1268
1782
  }
@@ -1276,7 +1790,14 @@ function createInputHandler(state) {
1276
1790
  }
1277
1791
  else if (state.mode === state_1.MODE.LOGS) {
1278
1792
  state.logAutoScroll = false;
1279
- state.logScrollOffset = Math.max(0, state.logLines.length - 1);
1793
+ const ggRows = process.stdout.rows ?? 24;
1794
+ const ggAvailable = Math.max(1, ggRows - 9);
1795
+ state.logScrollOffset = Math.max(0, state.logLines.length - ggAvailable);
1796
+ // Load all history so we can scroll to the very top
1797
+ if (!state.logHistoryLoaded) {
1798
+ state.logFetchedTailCount = 5000; // jump to 'all'
1799
+ loadMoreLogHistory(state);
1800
+ }
1280
1801
  }
1281
1802
  render(state);
1282
1803
  continue;
@@ -1330,6 +1851,10 @@ function cleanup(state) {
1330
1851
  state.execChild = null;
1331
1852
  }
1332
1853
  state.execActive = false;
1854
+ if (state.bottomSearchChild) {
1855
+ state.bottomSearchChild.kill('SIGTERM');
1856
+ state.bottomSearchChild = null;
1857
+ }
1333
1858
  for (const [, child] of state.bottomLogTails) {
1334
1859
  child.kill('SIGTERM');
1335
1860
  }
@@ -1351,7 +1876,7 @@ function cleanup(state) {
1351
1876
  if (state.statsTimer) {
1352
1877
  clearInterval(state.statsTimer);
1353
1878
  }
1354
- 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');
1355
1880
  }
1356
1881
  // Expose for testing
1357
1882
  function _getModuleState() {
@@ -1362,6 +1887,8 @@ function _setModuleState(ms) {
1362
1887
  }
1363
1888
  // --- Main ---
1364
1889
  function main() {
1890
+ // Enter alternate screen buffer so pre-launch output (e.g. npx install) is hidden
1891
+ process.stdout.write('\x1b[?1049h');
1365
1892
  const config = loadConfig();
1366
1893
  const state = (0, state_1.createState)(config);
1367
1894
  state.groups = discoverServices(config);