recomposable 1.1.2 → 1.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -27,12 +27,16 @@ export declare function doCascadeRebuild(state: AppState): void;
27
27
  export declare function enterExecInline(state: AppState): void;
28
28
  export declare function enterExec(state: AppState): void;
29
29
  export declare function exitExec(state: AppState): void;
30
+ export declare function shellEscape(str: string): string;
30
31
  export declare function runExecCommand(state: AppState): void;
31
32
  export declare function enterLogs(state: AppState): void;
32
33
  export declare function exitLogs(state: AppState): void;
34
+ export declare function loadMoreLogHistory(state: AppState): void;
33
35
  export declare function executeLogSearch(state: AppState): void;
34
36
  export declare function jumpToNextMatch(state: AppState): void;
35
37
  export declare function jumpToPrevMatch(state: AppState): void;
38
+ export declare function executeBottomSearch(state: AppState): void;
39
+ export declare function clearBottomSearch(state: AppState): void;
36
40
  export declare function handleKeypress(state: AppState, key: string): void;
37
41
  export declare function createInputHandler(state: AppState): (data: Buffer | string) => void;
38
42
  export declare function cleanup(state: AppState): void;
package/dist/index.js CHANGED
@@ -24,12 +24,16 @@ exports.doCascadeRebuild = doCascadeRebuild;
24
24
  exports.enterExecInline = enterExecInline;
25
25
  exports.enterExec = enterExec;
26
26
  exports.exitExec = exitExec;
27
+ exports.shellEscape = shellEscape;
27
28
  exports.runExecCommand = runExecCommand;
28
29
  exports.enterLogs = enterLogs;
29
30
  exports.exitLogs = exitLogs;
31
+ exports.loadMoreLogHistory = loadMoreLogHistory;
30
32
  exports.executeLogSearch = executeLogSearch;
31
33
  exports.jumpToNextMatch = jumpToNextMatch;
32
34
  exports.jumpToPrevMatch = jumpToPrevMatch;
35
+ exports.executeBottomSearch = executeBottomSearch;
36
+ exports.clearBottomSearch = clearBottomSearch;
33
37
  exports.handleKeypress = handleKeypress;
34
38
  exports.createInputHandler = createInputHandler;
35
39
  exports.cleanup = cleanup;
@@ -69,7 +73,33 @@ function loadConfig() {
69
73
  };
70
74
  const configPath = path_1.default.join(process.cwd(), 'recomposable.json');
71
75
  if (fs_1.default.existsSync(configPath)) {
72
- Object.assign(defaults, JSON.parse(fs_1.default.readFileSync(configPath, 'utf8')));
76
+ const raw = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
77
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
78
+ if (Array.isArray(raw.composeFiles) && raw.composeFiles.every((f) => typeof f === 'string')) {
79
+ defaults.composeFiles = raw.composeFiles;
80
+ }
81
+ if (Array.isArray(raw.logScanPatterns) && raw.logScanPatterns.every((p) => typeof p === 'string')) {
82
+ defaults.logScanPatterns = raw.logScanPatterns;
83
+ }
84
+ const numericFields = [
85
+ { key: 'pollInterval', min: 500, max: 300000 },
86
+ { key: 'logTailLines', min: 1, max: 50000 },
87
+ { key: 'logScanLines', min: 1, max: 50000 },
88
+ { key: 'logScanInterval', min: 1000, max: 600000 },
89
+ { key: 'statsInterval', min: 1000, max: 600000 },
90
+ { key: 'statsBufferSize', min: 1, max: 100 },
91
+ { key: 'bottomLogCount', min: 1, max: 200 },
92
+ { key: 'cpuWarnThreshold', min: 0, max: 10000 },
93
+ { key: 'cpuDangerThreshold', min: 0, max: 10000 },
94
+ { key: 'memWarnThreshold', min: 0, max: 1048576 },
95
+ { key: 'memDangerThreshold', min: 0, max: 1048576 },
96
+ ];
97
+ for (const { key, min, max } of numericFields) {
98
+ if (typeof raw[key] === 'number' && isFinite(raw[key]) && raw[key] >= min && raw[key] <= max) {
99
+ defaults[key] = raw[key];
100
+ }
101
+ }
102
+ }
73
103
  }
74
104
  const args = process.argv.slice(2);
75
105
  const cliFiles = [];
@@ -244,20 +274,26 @@ function pollContainerStats(state) {
244
274
  }
245
275
  // --- Rendering ---
246
276
  function render(state) {
247
- let output = (0, renderer_1.clearScreen)();
277
+ let view = '';
248
278
  if (state.mode === state_1.MODE.LIST) {
249
- output += (0, renderer_1.renderListView)(state);
279
+ view = (0, renderer_1.renderListView)(state);
250
280
  }
251
281
  else if (state.mode === state_1.MODE.LOGS) {
252
- output += (0, renderer_1.renderLogView)(state);
282
+ view = (0, renderer_1.renderLogView)(state);
253
283
  }
254
284
  else if (state.mode === state_1.MODE.EXEC) {
255
- output += (0, renderer_1.renderExecView)(state);
285
+ view = (0, renderer_1.renderExecView)(state);
256
286
  }
257
- process.stdout.write(output);
287
+ // View functions already embed CLEAR_EOL per line; just clear below last line
288
+ process.stdout.write((0, renderer_1.clearScreen)() + view + renderer_1.CLEAR_EOL + renderer_1.CLEAR_EOS);
258
289
  }
259
290
  function stripAnsi(str) {
260
- return str.replace(/\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[^[\]]/g, '');
291
+ return str.replace(
292
+ // CSI sequences: \x1b[ ... letter
293
+ // OSC sequences: \x1b] ... BEL or \x1b] ... ST
294
+ // DCS/APC/PM/SOS sequences: \x1bP/\x1b_/\x1b^/\x1bX ... ST (where ST = \x1b\\)
295
+ // Two-byte escape sequences: \x1b + any char
296
+ /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[P_^X][^\x1b]*(?:\x1b\\|\x07)|\x1b[^[\]P_^X]/g, '');
261
297
  }
262
298
  function throttledRender(state) {
263
299
  const now = Date.now();
@@ -284,6 +320,7 @@ function updateSelectedLogs(state) {
284
320
  return;
285
321
  state.bottomSearchQuery = '';
286
322
  state.bottomSearchActive = false;
323
+ clearBottomSearch(state);
287
324
  if (moduleState.logFetchTimer) {
288
325
  clearTimeout(moduleState.logFetchTimer);
289
326
  moduleState.logFetchTimer = null;
@@ -351,7 +388,7 @@ function doRebuild(state) {
351
388
  state.bottomLogTails.get(sk).kill('SIGTERM');
352
389
  state.bottomLogTails.delete(sk);
353
390
  }
354
- const child = (0, docker_1.rebuildService)(entry.file, entry.service, { noCache: state.noCache });
391
+ const child = (0, docker_1.rebuildService)(entry.file, entry.service, { noCache: state.noCache, noDeps: state.noDeps });
355
392
  state.rebuilding.set(sk, child);
356
393
  state.bottomLogLines.set(sk, { action: 'rebuilding', service: entry.service, lines: [] });
357
394
  let lineBuf = '';
@@ -366,24 +403,34 @@ function doRebuild(state) {
366
403
  if (newLines.length === 0)
367
404
  return;
368
405
  info.lines.push(...newLines);
369
- const maxLines = state.config.bottomLogCount || 10;
370
- if (info.lines.length > maxLines)
371
- info.lines = info.lines.slice(-maxLines);
406
+ if (state.mode === state_1.MODE.LOGS && state.logBuildKey === sk) {
407
+ state.logLines.push(...newLines);
408
+ if (state.logAutoScroll)
409
+ throttledRender(state);
410
+ }
372
411
  if (state.mode === state_1.MODE.LIST)
373
412
  throttledRender(state);
374
413
  };
375
414
  child.stdout.on('data', onData);
376
415
  child.stderr.on('data', onData);
377
416
  render(state);
378
- child.on('close', () => {
417
+ child.on('close', (code) => {
379
418
  state.rebuilding.delete(sk);
380
419
  state.containerStatsHistory.delete(sk);
381
420
  state.containerStats.delete(sk);
382
421
  pollStatuses(state);
383
422
  const info = state.bottomLogLines.get(sk);
423
+ if (code !== 0 && code !== null) {
424
+ if (info)
425
+ info.action = 'build_failed';
426
+ if (state.mode === state_1.MODE.LIST)
427
+ render(state);
428
+ return;
429
+ }
384
430
  if (info) {
385
431
  info.action = 'started';
386
- info.lines = [];
432
+ if (state.logBuildKey !== sk)
433
+ info.lines = [];
387
434
  }
388
435
  startBottomLogTail(state, sk, entry.file, entry.service);
389
436
  if (state.mode === state_1.MODE.LIST)
@@ -405,12 +452,19 @@ function doRestart(state) {
405
452
  state.restarting.set(sk, child);
406
453
  state.bottomLogLines.set(sk, { action: 'restarting', service: entry.service, lines: [] });
407
454
  render(state);
408
- child.on('close', () => {
455
+ child.on('close', (code) => {
409
456
  state.restarting.delete(sk);
410
457
  state.containerStatsHistory.delete(sk);
411
458
  state.containerStats.delete(sk);
412
459
  pollStatuses(state);
413
460
  const info = state.bottomLogLines.get(sk);
461
+ if (code !== 0 && code !== null) {
462
+ if (info)
463
+ info.action = 'restart_failed';
464
+ if (state.mode === state_1.MODE.LIST)
465
+ render(state);
466
+ return;
467
+ }
414
468
  if (info) {
415
469
  info.action = 'started';
416
470
  info.lines = [];
@@ -438,9 +492,16 @@ function doStop(state) {
438
492
  state.stopping.set(sk, child);
439
493
  state.bottomLogLines.set(sk, { action: 'stopping', service: entry.service, lines: [] });
440
494
  render(state);
441
- child.on('close', () => {
495
+ child.on('close', (code) => {
442
496
  state.stopping.delete(sk);
443
- state.bottomLogLines.delete(sk);
497
+ if (code !== 0 && code !== null) {
498
+ const info = state.bottomLogLines.get(sk);
499
+ if (info)
500
+ info.action = 'stop_failed';
501
+ }
502
+ else {
503
+ state.bottomLogLines.delete(sk);
504
+ }
444
505
  pollStatuses(state);
445
506
  if (state.mode === state_1.MODE.LIST)
446
507
  render(state);
@@ -460,10 +521,17 @@ function doStart(state) {
460
521
  state.starting.set(sk, child);
461
522
  state.bottomLogLines.set(sk, { action: 'starting', service: entry.service, lines: [] });
462
523
  render(state);
463
- child.on('close', () => {
524
+ child.on('close', (code) => {
464
525
  state.starting.delete(sk);
465
526
  pollStatuses(state);
466
527
  const info = state.bottomLogLines.get(sk);
528
+ if (code !== 0 && code !== null) {
529
+ if (info)
530
+ info.action = 'start_failed';
531
+ if (state.mode === state_1.MODE.LIST)
532
+ render(state);
533
+ return;
534
+ }
467
535
  if (info) {
468
536
  info.action = 'started';
469
537
  info.lines = [];
@@ -633,10 +701,9 @@ function executeCascadeStep(state, file, sk, cascade) {
633
701
  return;
634
702
  }
635
703
  step.status = 'in_progress';
636
- const maxLines = state.config.bottomLogCount || 10;
637
704
  let child;
638
705
  if (step.action === 'rebuild') {
639
- child = (0, docker_1.rebuildService)(file, step.service, { noCache: state.noCache });
706
+ child = (0, docker_1.rebuildService)(file, step.service, { noCache: state.noCache, noDeps: state.noDeps });
640
707
  }
641
708
  else {
642
709
  child = (0, docker_1.restartService)(file, step.service);
@@ -654,8 +721,11 @@ function executeCascadeStep(state, file, sk, cascade) {
654
721
  if (newLines.length === 0)
655
722
  return;
656
723
  info.lines.push(...newLines);
657
- if (info.lines.length > maxLines)
658
- info.lines = info.lines.slice(-maxLines);
724
+ if (state.mode === state_1.MODE.LOGS && state.logBuildKey === sk) {
725
+ state.logLines.push(...newLines);
726
+ if (state.logAutoScroll)
727
+ throttledRender(state);
728
+ }
659
729
  if (state.mode === state_1.MODE.LIST)
660
730
  throttledRender(state);
661
731
  };
@@ -753,13 +823,18 @@ function isCdCommand(cmd) {
753
823
  return null;
754
824
  return match[2] ? match[2].trim() : '';
755
825
  }
826
+ function shellEscape(str) {
827
+ return "'" + str.replace(/'/g, "'\\''") + "'";
828
+ }
756
829
  function runExecCommand(state) {
757
830
  const cmd = state.execInput.trim();
758
831
  if (!cmd || !state.execContainerId)
759
832
  return;
760
- // Add to history
833
+ // Add to history (capped at 1000 entries)
761
834
  if (state.execHistory.length === 0 || state.execHistory[state.execHistory.length - 1] !== cmd) {
762
835
  state.execHistory.push(cmd);
836
+ if (state.execHistory.length > 1000)
837
+ state.execHistory.shift();
763
838
  }
764
839
  state.execHistoryIdx = -1;
765
840
  state.execInput = '';
@@ -772,7 +847,7 @@ function runExecCommand(state) {
772
847
  // Handle cd commands — resolve new working directory
773
848
  const cdTarget = isCdCommand(cmd);
774
849
  if (cdTarget !== null) {
775
- const resolveCmd = cdTarget ? `cd ${cdTarget} && pwd` : 'cd && pwd';
850
+ const resolveCmd = cdTarget ? `cd ${shellEscape(cdTarget)} && pwd` : 'cd && pwd';
776
851
  const child = (0, docker_1.execInContainer)(state.execContainerId, resolveCmd, state.execCwd || undefined);
777
852
  state.execChild = child;
778
853
  let stdout = '';
@@ -785,8 +860,9 @@ function runExecCommand(state) {
785
860
  if (code === 0) {
786
861
  const lines = stdout.trim().split('\n');
787
862
  const newCwd = lines[lines.length - 1].trim();
788
- if (newCwd)
863
+ if (newCwd && newCwd.startsWith('/') && newCwd.length < 4096 && !/[\x00-\x1f]/.test(newCwd)) {
789
864
  state.execCwd = newCwd;
865
+ }
790
866
  }
791
867
  else {
792
868
  const errLines = stderr.trim().split('\n').filter(Boolean);
@@ -841,44 +917,65 @@ function enterLogs(state) {
841
917
  clearTimeout(moduleState.logFetchTimer);
842
918
  moduleState.logFetchTimer = null;
843
919
  }
920
+ // Carry over bottom panel search query to full log search
921
+ const carryQuery = state.bottomSearchQuery || '';
922
+ clearBottomSearch(state);
923
+ const sk = (0, state_1.statusKey)(entry.file, entry.service);
924
+ const info = state.bottomLogLines.get(sk);
925
+ const isBuilding = state.rebuilding.has(sk) || state.cascading.has(sk);
926
+ const isBuildFailed = info && info.action === 'build_failed';
844
927
  state.mode = state_1.MODE.LOGS;
845
928
  state.logLines = [];
846
929
  state.logScrollOffset = 0;
847
930
  state.logAutoScroll = true;
848
- state.logSearchQuery = '';
931
+ state.logSearchQuery = carryQuery;
849
932
  state.logSearchActive = false;
850
933
  state.logSearchMatches = [];
851
934
  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));
935
+ state.logFetchedTailCount = 200;
936
+ state.logHistoryLoaded = false;
937
+ state.logHistoryLoading = false;
938
+ state.logSearchPending = !!carryQuery;
939
+ state.logHistoryChild = null;
940
+ if (isBuilding || isBuildFailed) {
941
+ // Show build output instead of runtime logs
942
+ state.logBuildKey = sk;
943
+ if (info) {
944
+ state.logLines = [...info.lines];
863
945
  }
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);
946
+ state.logHistoryLoaded = true;
947
+ }
948
+ else {
949
+ state.logBuildKey = null;
950
+ const child = (0, docker_1.tailLogs)(entry.file, entry.service, 200);
951
+ state.logChild = child;
952
+ let lineBuf = '';
953
+ const onData = (data) => {
954
+ lineBuf += data.toString();
955
+ const parts = lineBuf.split(/\r?\n|\r/);
956
+ lineBuf = parts.pop();
957
+ if (parts.length === 0)
958
+ return;
959
+ for (const line of parts) {
960
+ state.logLines.push(stripAnsi(line));
869
961
  }
962
+ if (state.logAutoScroll) {
963
+ throttledRender(state);
964
+ }
965
+ };
966
+ child.stdout.on('data', onData);
967
+ child.stderr.on('data', onData);
968
+ child.on('close', () => {
969
+ if (state.logChild === child) {
970
+ state.logChild = null;
971
+ }
972
+ });
973
+ // If carrying a search query from bottom panel, load full history to search
974
+ if (carryQuery) {
975
+ state.logFetchedTailCount = 5000;
976
+ loadMoreLogHistory(state);
870
977
  }
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
- });
978
+ }
882
979
  render(state);
883
980
  }
884
981
  function exitLogs(state) {
@@ -886,11 +983,72 @@ function exitLogs(state) {
886
983
  state.logChild.kill('SIGTERM');
887
984
  state.logChild = null;
888
985
  }
986
+ if (state.logHistoryChild) {
987
+ state.logHistoryChild.kill('SIGTERM');
988
+ state.logHistoryChild = null;
989
+ }
889
990
  state.logLines = [];
991
+ state.logBuildKey = null;
992
+ state.logHistoryLoaded = false;
993
+ state.logHistoryLoading = false;
994
+ state.logSearchPending = false;
890
995
  state.mode = state_1.MODE.LIST;
891
996
  pollStatuses(state);
892
997
  render(state);
893
998
  }
999
+ // --- Log History Loading ---
1000
+ function loadMoreLogHistory(state) {
1001
+ if (state.logHistoryLoaded || state.logHistoryLoading)
1002
+ return;
1003
+ const entry = (0, state_1.selectedEntry)(state);
1004
+ if (!entry)
1005
+ return;
1006
+ // Escalate: 200 → 1000 → 5000 → all
1007
+ let nextTail;
1008
+ if (state.logFetchedTailCount < 1000)
1009
+ nextTail = 1000;
1010
+ else if (state.logFetchedTailCount < 5000)
1011
+ nextTail = 5000;
1012
+ else
1013
+ nextTail = 'all';
1014
+ state.logHistoryLoading = true;
1015
+ const snapshotLen = state.logLines.length;
1016
+ const child = (0, docker_1.fetchServiceLogs)(entry.file, entry.service, nextTail);
1017
+ state.logHistoryChild = child;
1018
+ let output = '';
1019
+ child.stdout.on('data', (d) => { output += d.toString(); });
1020
+ child.stderr.on('data', (d) => { output += d.toString(); });
1021
+ child.on('close', () => {
1022
+ if (state.logHistoryChild === child) {
1023
+ state.logHistoryChild = null;
1024
+ }
1025
+ state.logHistoryLoading = false;
1026
+ const fetchedLines = output.split(/\r?\n|\r/).filter(l => l.length > 0).map(stripAnsi).filter(Boolean);
1027
+ if (fetchedLines.length <= snapshotLen) {
1028
+ // No more history available
1029
+ state.logHistoryLoaded = true;
1030
+ }
1031
+ else {
1032
+ // Merge: fetched history + any new lines that arrived during the fetch
1033
+ const newLiveLines = state.logLines.slice(snapshotLen);
1034
+ const oldOffset = state.logScrollOffset;
1035
+ const added = fetchedLines.length - snapshotLen;
1036
+ state.logLines = [...fetchedLines, ...newLiveLines];
1037
+ // Adjust scroll offset to maintain visual position
1038
+ if (!state.logAutoScroll) {
1039
+ state.logScrollOffset = oldOffset + added;
1040
+ }
1041
+ state.logFetchedTailCount = nextTail === 'all' ? Infinity : nextTail;
1042
+ if (nextTail === 'all')
1043
+ state.logHistoryLoaded = true;
1044
+ }
1045
+ if (state.logSearchPending) {
1046
+ state.logSearchPending = false;
1047
+ executeLogSearch(state);
1048
+ }
1049
+ render(state);
1050
+ });
1051
+ }
894
1052
  // --- Log Search ---
895
1053
  function executeLogSearch(state) {
896
1054
  const query = state.logSearchQuery;
@@ -914,7 +1072,8 @@ function scrollToLogLine(state, lineIdx) {
914
1072
  const headerHeight = 9;
915
1073
  const availableRows = Math.max(1, rows - headerHeight);
916
1074
  const totalLines = state.logLines.length;
917
- state.logScrollOffset = Math.max(0, totalLines - lineIdx - Math.floor(availableRows / 2));
1075
+ const maxOffset = Math.max(0, totalLines - availableRows);
1076
+ state.logScrollOffset = Math.min(maxOffset, Math.max(0, totalLines - lineIdx - Math.floor(availableRows / 2)));
918
1077
  state.logAutoScroll = state.logScrollOffset === 0;
919
1078
  render(state);
920
1079
  }
@@ -930,6 +1089,78 @@ function jumpToPrevMatch(state) {
930
1089
  state.logSearchMatchIdx = (state.logSearchMatchIdx - 1 + state.logSearchMatches.length) % state.logSearchMatches.length;
931
1090
  scrollToLogLine(state, state.logSearchMatches[state.logSearchMatchIdx]);
932
1091
  }
1092
+ // --- Bottom Panel Search ---
1093
+ function executeBottomSearch(state) {
1094
+ const entry = (0, state_1.selectedEntry)(state);
1095
+ if (!entry || !state.bottomSearchQuery)
1096
+ return;
1097
+ const sk = (0, state_1.statusKey)(entry.file, entry.service);
1098
+ const info = state.bottomLogLines.get(sk);
1099
+ if (!info)
1100
+ return;
1101
+ // Save current tail lines so we can restore them later
1102
+ if (!state.bottomSearchSavedLines.has(sk)) {
1103
+ state.bottomSearchSavedLines.set(sk, [...info.lines]);
1104
+ }
1105
+ // Kill any previous search fetch
1106
+ if (state.bottomSearchChild) {
1107
+ state.bottomSearchChild.kill('SIGTERM');
1108
+ state.bottomSearchChild = null;
1109
+ }
1110
+ state.bottomSearchLoading = true;
1111
+ state.bottomSearchTotalMatches = 0;
1112
+ render(state);
1113
+ const child = (0, docker_1.fetchServiceLogs)(entry.file, entry.service, 'all');
1114
+ state.bottomSearchChild = child;
1115
+ let output = '';
1116
+ child.stdout.on('data', (d) => { output += d.toString(); });
1117
+ child.stderr.on('data', (d) => { output += d.toString(); });
1118
+ child.on('close', () => {
1119
+ if (state.bottomSearchChild !== child)
1120
+ return; // superseded
1121
+ state.bottomSearchChild = null;
1122
+ state.bottomSearchLoading = false;
1123
+ const query = state.bottomSearchQuery;
1124
+ if (!query) {
1125
+ clearBottomSearch(state);
1126
+ render(state);
1127
+ return;
1128
+ }
1129
+ const lowerQuery = query.toLowerCase();
1130
+ const allLines = output.split(/\r?\n|\r/).filter(l => l.trim().length > 0).map(stripAnsi).filter(Boolean);
1131
+ const matchingLines = [];
1132
+ for (const line of allLines) {
1133
+ if (line.toLowerCase().includes(lowerQuery)) {
1134
+ matchingLines.push(line);
1135
+ }
1136
+ }
1137
+ state.bottomSearchTotalMatches = matchingLines.length;
1138
+ // Show the last N matching lines in the bottom panel
1139
+ const maxLines = state.config.bottomLogCount || 10;
1140
+ const currentInfo = state.bottomLogLines.get(sk);
1141
+ if (currentInfo) {
1142
+ currentInfo.lines = matchingLines.slice(-maxLines);
1143
+ }
1144
+ if (state.mode === state_1.MODE.LIST)
1145
+ render(state);
1146
+ });
1147
+ }
1148
+ function clearBottomSearch(state) {
1149
+ if (state.bottomSearchChild) {
1150
+ state.bottomSearchChild.kill('SIGTERM');
1151
+ state.bottomSearchChild = null;
1152
+ }
1153
+ state.bottomSearchLoading = false;
1154
+ state.bottomSearchTotalMatches = 0;
1155
+ // Restore saved tail lines
1156
+ if (state.selectedLogKey && state.bottomSearchSavedLines.has(state.selectedLogKey)) {
1157
+ const info = state.bottomLogLines.get(state.selectedLogKey);
1158
+ if (info) {
1159
+ info.lines = state.bottomSearchSavedLines.get(state.selectedLogKey);
1160
+ }
1161
+ state.bottomSearchSavedLines.delete(state.selectedLogKey);
1162
+ }
1163
+ }
933
1164
  // --- Input Handling ---
934
1165
  function handleKeypress(state, key) {
935
1166
  if (key === '\x03' && state.mode !== state_1.MODE.EXEC && !state.execActive) {
@@ -1002,8 +1233,22 @@ function handleKeypress(state, key) {
1002
1233
  }
1003
1234
  else if (key === '\r') {
1004
1235
  state.logSearchActive = false;
1005
- executeLogSearch(state);
1006
- render(state);
1236
+ if (!state.logHistoryLoaded && !state.logHistoryLoading) {
1237
+ // Load all history, then search
1238
+ state.logSearchPending = true;
1239
+ state.logFetchedTailCount = 5000; // jump straight to 'all'
1240
+ loadMoreLogHistory(state);
1241
+ render(state);
1242
+ }
1243
+ else if (state.logHistoryLoading) {
1244
+ // Already loading — search will run when it finishes
1245
+ state.logSearchPending = true;
1246
+ render(state);
1247
+ }
1248
+ else {
1249
+ executeLogSearch(state);
1250
+ render(state);
1251
+ }
1007
1252
  }
1008
1253
  else if (key === '\x7f' || key === '\b') {
1009
1254
  state.logSearchQuery = state.logSearchQuery.slice(0, -1);
@@ -1017,12 +1262,31 @@ function handleKeypress(state, key) {
1017
1262
  }
1018
1263
  const rows = process.stdout.rows ?? 24;
1019
1264
  const pageSize = Math.max(1, Math.floor(rows / 2));
1020
- const maxOffset = Math.max(0, state.logLines.length - 1);
1265
+ const availableRows = Math.max(1, rows - 9);
1266
+ const maxOffset = Math.max(0, state.logLines.length - availableRows);
1267
+ // Trigger lazy history load when scrolled near the top
1268
+ const checkLoadMore = () => {
1269
+ const availableRows = Math.max(1, rows - 9);
1270
+ const linesFromTop = state.logLines.length - state.logScrollOffset - availableRows;
1271
+ if (linesFromTop < availableRows) {
1272
+ loadMoreLogHistory(state);
1273
+ }
1274
+ };
1021
1275
  switch (key) {
1022
1276
  case 'f':
1023
- case '\x1b':
1024
1277
  exitLogs(state);
1025
1278
  break;
1279
+ case '\x1b':
1280
+ if (state.logSearchQuery) {
1281
+ state.logSearchQuery = '';
1282
+ state.logSearchMatches = [];
1283
+ state.logSearchMatchIdx = -1;
1284
+ render(state);
1285
+ }
1286
+ else {
1287
+ exitLogs(state);
1288
+ }
1289
+ break;
1026
1290
  case 'q':
1027
1291
  cleanup(state);
1028
1292
  process.exit(0);
@@ -1031,6 +1295,7 @@ function handleKeypress(state, key) {
1031
1295
  case '\x1b[A':
1032
1296
  state.logAutoScroll = false;
1033
1297
  state.logScrollOffset = Math.min(maxOffset, state.logScrollOffset + 1);
1298
+ checkLoadMore();
1034
1299
  render(state);
1035
1300
  break;
1036
1301
  case 'j':
@@ -1050,6 +1315,7 @@ function handleKeypress(state, key) {
1050
1315
  case '\x15': // Ctrl+U
1051
1316
  state.logAutoScroll = false;
1052
1317
  state.logScrollOffset = Math.min(maxOffset, state.logScrollOffset + pageSize);
1318
+ checkLoadMore();
1053
1319
  render(state);
1054
1320
  break;
1055
1321
  case '\x04': // Ctrl+D
@@ -1135,10 +1401,14 @@ function handleKeypress(state, key) {
1135
1401
  if (key === '\x1b') {
1136
1402
  state.bottomSearchActive = false;
1137
1403
  state.bottomSearchQuery = '';
1404
+ clearBottomSearch(state);
1138
1405
  render(state);
1139
1406
  }
1140
1407
  else if (key === '\r') {
1141
1408
  state.bottomSearchActive = false;
1409
+ if (state.bottomSearchQuery) {
1410
+ executeBottomSearch(state);
1411
+ }
1142
1412
  render(state);
1143
1413
  }
1144
1414
  else if (key === '\x7f' || key === '\b') {
@@ -1201,6 +1471,10 @@ function handleKeypress(state, key) {
1201
1471
  state.noCache = !state.noCache;
1202
1472
  render(state);
1203
1473
  break;
1474
+ case 'o':
1475
+ state.noDeps = !state.noDeps;
1476
+ render(state);
1477
+ break;
1204
1478
  case 'f':
1205
1479
  case '\r':
1206
1480
  enterLogs(state);
@@ -1276,7 +1550,14 @@ function createInputHandler(state) {
1276
1550
  }
1277
1551
  else if (state.mode === state_1.MODE.LOGS) {
1278
1552
  state.logAutoScroll = false;
1279
- state.logScrollOffset = Math.max(0, state.logLines.length - 1);
1553
+ const ggRows = process.stdout.rows ?? 24;
1554
+ const ggAvailable = Math.max(1, ggRows - 9);
1555
+ state.logScrollOffset = Math.max(0, state.logLines.length - ggAvailable);
1556
+ // Load all history so we can scroll to the very top
1557
+ if (!state.logHistoryLoaded) {
1558
+ state.logFetchedTailCount = 5000; // jump to 'all'
1559
+ loadMoreLogHistory(state);
1560
+ }
1280
1561
  }
1281
1562
  render(state);
1282
1563
  continue;
@@ -1330,6 +1611,10 @@ function cleanup(state) {
1330
1611
  state.execChild = null;
1331
1612
  }
1332
1613
  state.execActive = false;
1614
+ if (state.bottomSearchChild) {
1615
+ state.bottomSearchChild.kill('SIGTERM');
1616
+ state.bottomSearchChild = null;
1617
+ }
1333
1618
  for (const [, child] of state.bottomLogTails) {
1334
1619
  child.kill('SIGTERM');
1335
1620
  }