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.
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CLEAR_EOS = exports.CLEAR_EOL = void 0;
3
4
  exports.visLen = visLen;
4
5
  exports.padVisible = padVisible;
5
6
  exports.padVisibleStart = padVisibleStart;
@@ -13,6 +14,7 @@ exports.renderLegend = renderLegend;
13
14
  exports.renderListView = renderListView;
14
15
  exports.truncateLine = truncateLine;
15
16
  exports.highlightSearchInLine = highlightSearchInLine;
17
+ exports.wrapPlainLine = wrapPlainLine;
16
18
  exports.renderLogView = renderLogView;
17
19
  exports.renderExecView = renderExecView;
18
20
  const state_1 = require("./state");
@@ -46,7 +48,21 @@ function padVisibleStart(str, width) {
46
48
  const pad = Math.max(0, width - visLen(str));
47
49
  return ' '.repeat(pad) + str;
48
50
  }
51
+ exports.CLEAR_EOL = `${ESC}K`;
52
+ exports.CLEAR_EOS = `${ESC}J`;
49
53
  const PATTERN_COLORS = [FG_YELLOW, FG_RED, FG_CYAN, FG_WHITE];
54
+ function logLineColor(line, patterns) {
55
+ let color = null;
56
+ for (let pi = 0; pi < patterns.length; pi++) {
57
+ if (line.includes(patterns[pi])) {
58
+ color = PATTERN_COLORS[pi % PATTERN_COLORS.length];
59
+ }
60
+ }
61
+ return color;
62
+ }
63
+ // Cached separator line — recomputed only when terminal width changes
64
+ let cachedSepColumns = 0;
65
+ let cachedSepLine = '';
50
66
  function patternLabel(pattern) {
51
67
  return pattern.replace(/^[\[\(\{<]/, '').replace(/[\]\)\}>]$/, '');
52
68
  }
@@ -76,7 +92,14 @@ function relativeTime(ts) {
76
92
  return `${DIM}${days}d ago${RESET}`;
77
93
  }
78
94
  function clearScreen() {
79
- return `${ESC}2J${ESC}H${ESC}?25l`;
95
+ return `${ESC}H${ESC}?25l`;
96
+ }
97
+ function separatorLine(columns) {
98
+ if (columns !== cachedSepColumns) {
99
+ cachedSepColumns = columns;
100
+ cachedSepLine = ` ${FG_GRAY}${'\u2500'.repeat(Math.max(0, columns - 2))}${RESET}`;
101
+ }
102
+ return cachedSepLine;
80
103
  }
81
104
  function showCursor() {
82
105
  return `${ESC}?25h`;
@@ -133,7 +156,7 @@ function formatMem(bytes) {
133
156
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
134
157
  }
135
158
  function renderLegend(opts = {}) {
136
- const { logPanelActive = false, logsScrollMode = false, noCacheActive = false, watchActive = false, execMode = false, execInline = false } = opts;
159
+ const { logPanelActive = false, logsScrollMode = false, noCacheActive = false, noDepsActive = false, watchActive = false, execMode = false, execInline = false } = opts;
137
160
  const item = (text, active) => {
138
161
  if (active)
139
162
  return `${BG_HIGHLIGHT} ${text} ${RESET}`;
@@ -159,8 +182,9 @@ function renderLegend(opts = {}) {
159
182
  ].join(' ');
160
183
  }
161
184
  if (logsScrollMode) {
185
+ const hasSearch = opts.hasLogSearch || false;
162
186
  return [
163
- item('[Esc] back', false),
187
+ item(hasSearch ? '[Esc] clear search' : '[Esc] back', false),
164
188
  item('[j/k] scroll', false),
165
189
  item('[G] bottom', false),
166
190
  item('[gg] top', false),
@@ -176,6 +200,7 @@ function renderLegend(opts = {}) {
176
200
  item('Sto[P]', false),
177
201
  item('[W]atch', watchActive),
178
202
  item('[N]o cache', noCacheActive),
203
+ item('n[O] deps', noDepsActive),
179
204
  item('[e]xec', false),
180
205
  item('[F]ull logs', false),
181
206
  item('[L]og panel', logPanelActive),
@@ -186,6 +211,7 @@ function renderListView(state) {
186
211
  const columns = process.stdout.columns ?? 80;
187
212
  const rows = process.stdout.rows ?? 24;
188
213
  const patterns = state.config.logScanPatterns || [];
214
+ const sep = separatorLine(columns);
189
215
  const buf = [];
190
216
  for (const line of LOGO) {
191
217
  buf.push(line);
@@ -193,13 +219,13 @@ function renderListView(state) {
193
219
  const watchActive = state.watching.size > 0;
194
220
  const help = state.execActive
195
221
  ? renderLegend({ execInline: true })
196
- : renderLegend({ logPanelActive: state.showBottomLogs, noCacheActive: state.noCache, watchActive });
197
- buf.push(` ${FG_GRAY}${'\u2500'.repeat(Math.max(0, columns - 2))}${RESET}`);
222
+ : renderLegend({ logPanelActive: state.showBottomLogs, noCacheActive: state.noCache, noDepsActive: state.noDeps, watchActive });
223
+ buf.push(sep);
198
224
  buf.push(` ${help}`);
199
225
  const headerHeight = buf.length;
200
226
  const bottomBuf = [];
201
227
  if (state.execActive && state.execService) {
202
- bottomBuf.push(` ${FG_GRAY}${'\u2500'.repeat(Math.max(0, columns - 2))}${RESET}`);
228
+ bottomBuf.push(sep);
203
229
  const runningIndicator = state.execChild ? `${FG_YELLOW}running${RESET}` : `${FG_GREEN}ready${RESET}`;
204
230
  const cwdInfo = state.execCwd ? ` ${DIM}${state.execCwd}${RESET}` : '';
205
231
  bottomBuf.push(` ${FG_CYAN}exec ${BOLD}${state.execService}${RESET} ${runningIndicator}${cwdInfo}`);
@@ -217,7 +243,7 @@ function renderListView(state) {
217
243
  // Check for cascade progress
218
244
  const cascade = state.cascading.get(sk);
219
245
  if (cascade) {
220
- bottomBuf.push(` ${FG_GRAY}${'\u2500'.repeat(Math.max(0, columns - 2))}${RESET}`);
246
+ bottomBuf.push(sep);
221
247
  bottomBuf.push(` ${FG_YELLOW}cascading ${BOLD}${selEntry.service}${RESET}`);
222
248
  for (let si = 0; si < cascade.steps.length; si++) {
223
249
  const step = cascade.steps[si];
@@ -240,22 +266,36 @@ function renderListView(state) {
240
266
  const info = state.bottomLogLines.get(sk);
241
267
  if (info) {
242
268
  if (!cascade) {
243
- bottomBuf.push(` ${FG_GRAY}${'\u2500'.repeat(Math.max(0, columns - 2))}${RESET}`);
269
+ bottomBuf.push(sep);
244
270
  }
245
- const actionColor = info.action === 'rebuilding' || info.action === 'restarting' || info.action === 'stopping' || info.action === 'starting' || info.action === 'cascading' ? FG_YELLOW
246
- : info.action === 'watching' ? FG_CYAN : FG_GREEN;
247
- let headerLine = ` ${actionColor}${info.action} ${BOLD}${info.service}${RESET}`;
271
+ const isFailed = info.action === 'build_failed' || info.action === 'restart_failed' || info.action === 'stop_failed' || info.action === 'start_failed';
272
+ const actionColor = isFailed ? FG_RED
273
+ : info.action === 'rebuilding' || info.action === 'restarting' || info.action === 'stopping' || info.action === 'starting' || info.action === 'cascading' ? FG_YELLOW
274
+ : info.action === 'watching' ? FG_CYAN : FG_GREEN;
275
+ const actionLabel = isFailed ? info.action.replace('_', ' ').toUpperCase() : info.action;
276
+ let headerLine = ` ${actionColor}${actionLabel} ${BOLD}${info.service}${RESET}`;
248
277
  const bq = state.bottomSearchQuery || '';
249
278
  if (bq && !state.bottomSearchActive) {
250
- const matchCount = info.lines.filter(l => l.toLowerCase().includes(bq.toLowerCase())).length;
251
- headerLine += matchCount > 0
252
- ? ` ${DIM}search: "${bq}" (${matchCount} match${matchCount !== 1 ? 'es' : ''})${RESET}`
253
- : ` ${FG_RED}search: "${bq}" (no matches)${RESET}`;
279
+ if (state.bottomSearchLoading) {
280
+ headerLine += ` ${FG_YELLOW}searching "${bq}"...${RESET}`;
281
+ }
282
+ else {
283
+ const totalMatches = state.bottomSearchTotalMatches;
284
+ headerLine += totalMatches > 0
285
+ ? ` ${DIM}search: "${bq}" (${totalMatches} match${totalMatches !== 1 ? 'es' : ''} in full log)${RESET}`
286
+ : ` ${FG_RED}search: "${bq}" (no matches)${RESET}`;
287
+ }
254
288
  }
255
289
  bottomBuf.push(headerLine);
290
+ if (info.lines.length === 0 && info.action === 'logs') {
291
+ bottomBuf.push(` ${DIM}loading...${RESET}`);
292
+ }
256
293
  const searchQuery = bq && !state.bottomSearchActive ? bq : '';
257
- for (const line of info.lines) {
294
+ const maxBottomLines = state.config.bottomLogCount || 10;
295
+ const visibleLines = info.lines.slice(-maxBottomLines);
296
+ for (const line of visibleLines) {
258
297
  let coloredLine = line.substring(0, columns - 4);
298
+ const lineColor = logLineColor(coloredLine, patterns) || FG_GRAY;
259
299
  if (searchQuery) {
260
300
  const lowerLine = coloredLine.toLowerCase();
261
301
  const lowerQ = searchQuery.toLowerCase();
@@ -269,20 +309,13 @@ function renderListView(state) {
269
309
  break;
270
310
  }
271
311
  result += coloredLine.substring(pos, idx);
272
- result += `${REVERSE}${FG_YELLOW}${coloredLine.substring(idx, idx + searchQuery.length)}${RESET}${FG_GRAY}`;
312
+ result += `${REVERSE}${FG_YELLOW}${coloredLine.substring(idx, idx + searchQuery.length)}${RESET}${lineColor}`;
273
313
  pos = idx + searchQuery.length;
274
314
  }
275
315
  coloredLine = result;
276
316
  }
277
317
  }
278
- for (let pi = 0; pi < patterns.length; pi++) {
279
- const p = patterns[pi];
280
- if (coloredLine.includes(p)) {
281
- const color = PATTERN_COLORS[pi % PATTERN_COLORS.length];
282
- coloredLine = coloredLine.split(p).join(`${color}${p}${RESET}${FG_GRAY}`);
283
- }
284
- }
285
- bottomBuf.push(` ${FG_GRAY}${coloredLine}${RESET}`);
318
+ bottomBuf.push(` ${lineColor}${coloredLine}${RESET}`);
286
319
  }
287
320
  if (state.bottomSearchActive) {
288
321
  bottomBuf.push(`${BOLD}/${RESET}${state.bottomSearchQuery}${BOLD}_${RESET}`);
@@ -291,100 +324,113 @@ function renderListView(state) {
291
324
  }
292
325
  }
293
326
  const bottomHeight = bottomBuf.length;
294
- const lines = [];
327
+ // Pass 1: build lightweight stubs (type + index only, no text computation)
328
+ const stubs = [];
295
329
  let currentGroup = -1;
296
330
  for (let i = 0; i < state.flatList.length; i++) {
297
331
  const entry = state.flatList[i];
298
332
  if (entry.groupIdx !== currentGroup) {
299
333
  currentGroup = entry.groupIdx;
300
- const group = state.groups[entry.groupIdx];
301
- if (lines.length > 0)
302
- lines.push({ type: 'blank', text: '' });
303
- const label = ` ${BOLD}${group.label}${RESET}`;
304
- if (group.error) {
305
- lines.push({ type: 'header', text: `${label} ${FG_RED}(${group.error})${RESET}` });
306
- }
307
- else {
308
- lines.push({ type: 'header', text: label });
309
- }
310
- let colHeader = `${DIM} ${'SERVICE'.padEnd(24)} ${'STATUS'.padEnd(22)} ${'BUILT'.padEnd(12)} ${'RESTARTED'.padEnd(12)}`;
311
- for (const p of patterns)
312
- colHeader += patternLabel(p).padStart(5) + ' ';
313
- colHeader += ` ${'CPU/MEM'.padStart(16)} ${'PORTS'.padEnd(14)}`;
314
- lines.push({ type: 'colheader', text: colHeader + RESET });
315
- }
316
- const sk = (0, state_1.statusKey)(entry.file, entry.service);
317
- const st = state.statuses.get(sk);
318
- const rebuilding = state.rebuilding.has(sk);
319
- const restarting = state.restarting.has(sk);
320
- const stopping = state.stopping.has(sk);
321
- const starting = state.starting.has(sk);
322
- const isWatching = state.watching.has(sk);
323
- const isCascading = state.cascading.has(sk);
324
- const icon = statusIcon(st, rebuilding || isCascading, restarting, stopping, starting);
325
- const stext = statusText(st, rebuilding || isCascading, restarting, stopping, starting);
326
- const watchIndicator = isWatching ? `${FG_CYAN}W${RESET}` : ' ';
327
- const name = entry.service.padEnd(24);
328
- const statusPadded = padVisible(stext, 22);
329
- let cpuMemStr;
330
- const stats = state.containerStats ? state.containerStats.get(sk) : null;
331
- if (stats && st && st.state === 'running') {
332
- const cpu = stats.cpuPercent;
333
- const mem = stats.memUsageBytes;
334
- const cpuWarn = state.config.cpuWarnThreshold || 50;
335
- const cpuDanger = state.config.cpuDangerThreshold || 100;
336
- const memWarn = (state.config.memWarnThreshold || 512) * 1024 * 1024;
337
- const memDanger = (state.config.memDangerThreshold || 1024) * 1024 * 1024;
338
- let color = DIM;
339
- if (cpu > cpuDanger || mem > memDanger)
340
- color = FG_RED;
341
- else if (cpu > cpuWarn || mem > memWarn)
342
- color = FG_YELLOW;
343
- const cpuText = cpu.toFixed(1) + '%';
344
- const memText = formatMem(mem);
345
- cpuMemStr = padVisible(`${color}${cpuText} / ${memText}${RESET}`, 16);
346
- }
347
- else {
348
- cpuMemStr = padVisible(`${DIM}-${RESET}`, 16);
349
- }
350
- let portsStr;
351
- if (st && st.ports && st.ports.length > 0) {
352
- const portsText = st.ports.map(p => p.published).join(' ');
353
- portsStr = padVisible(`${DIM}${portsText}${RESET}`, 14);
354
- }
355
- else {
356
- portsStr = padVisible(`${DIM}-${RESET}`, 14);
357
- }
358
- const built = padVisible(relativeTime(st ? st.createdAt : null), 12);
359
- const restarted = padVisible(relativeTime(st ? st.startedAt : null), 12);
360
- const pointer = i === state.cursor ? `${REVERSE}` : '';
361
- const endPointer = i === state.cursor ? `${RESET}` : '';
362
- let countsStr = '';
363
- const logCounts = state.logCounts.get(sk);
364
- for (let pi = 0; pi < patterns.length; pi++) {
365
- const count = logCounts ? (logCounts.get(patterns[pi]) || 0) : 0;
366
- const color = count > 0 ? PATTERN_COLORS[pi % PATTERN_COLORS.length] : DIM;
367
- const countText = count > 0 ? `${color}${count}${RESET}` : `${color}-${RESET}`;
368
- countsStr += padVisibleStart(countText, 5) + ' ';
334
+ if (stubs.length > 0)
335
+ stubs.push({ type: 'blank', flatIdx: -1, groupIdx: entry.groupIdx });
336
+ stubs.push({ type: 'header', flatIdx: -1, groupIdx: entry.groupIdx });
337
+ stubs.push({ type: 'colheader', flatIdx: -1, groupIdx: entry.groupIdx });
369
338
  }
370
- lines.push({
371
- type: 'service',
372
- text: `${pointer} ${watchIndicator}${icon} ${FG_WHITE}${name}${RESET} ${statusPadded} ${built} ${restarted}${countsStr} ${cpuMemStr} ${portsStr}${endPointer}`,
373
- flatIdx: i,
374
- });
339
+ stubs.push({ type: 'service', flatIdx: i, groupIdx: entry.groupIdx });
375
340
  }
376
341
  const availableRows = Math.max(3, rows - headerHeight - bottomHeight);
377
- const cursorLineIdx = lines.findIndex(l => l.type === 'service' && l.flatIdx === state.cursor);
378
- if (cursorLineIdx < state.scrollOffset) {
379
- state.scrollOffset = cursorLineIdx;
342
+ // Find cursor position in stubs
343
+ const cursorStubIdx = stubs.findIndex(s => s.type === 'service' && s.flatIdx === state.cursor);
344
+ if (cursorStubIdx < state.scrollOffset) {
345
+ state.scrollOffset = cursorStubIdx;
380
346
  }
381
- else if (cursorLineIdx >= state.scrollOffset + availableRows) {
382
- state.scrollOffset = cursorLineIdx - availableRows + 1;
347
+ else if (cursorStubIdx >= state.scrollOffset + availableRows) {
348
+ state.scrollOffset = cursorStubIdx - availableRows + 1;
383
349
  }
384
- state.scrollOffset = Math.max(0, Math.min(lines.length - availableRows, state.scrollOffset));
385
- const visible = lines.slice(state.scrollOffset, state.scrollOffset + availableRows);
386
- for (const line of visible) {
387
- buf.push(line.text || '');
350
+ state.scrollOffset = Math.max(0, Math.min(stubs.length - availableRows, state.scrollOffset));
351
+ // Pass 2: render text only for visible stubs
352
+ const visEnd = Math.min(stubs.length, state.scrollOffset + availableRows);
353
+ for (let si = state.scrollOffset; si < visEnd; si++) {
354
+ const stub = stubs[si];
355
+ switch (stub.type) {
356
+ case 'blank':
357
+ buf.push('');
358
+ break;
359
+ case 'header': {
360
+ const group = state.groups[stub.groupIdx];
361
+ const label = ` ${BOLD}${group.label}${RESET}`;
362
+ buf.push(group.error ? `${label} ${FG_RED}(${group.error})${RESET}` : label);
363
+ break;
364
+ }
365
+ case 'colheader': {
366
+ let colHeader = `${DIM} ${'SERVICE'.padEnd(24)} ${'STATUS'.padEnd(22)} ${'BUILT'.padEnd(12)} ${'RESTARTED'.padEnd(12)}`;
367
+ for (const p of patterns)
368
+ colHeader += patternLabel(p).padStart(5) + ' ';
369
+ colHeader += ` ${'CPU/MEM'.padStart(16)} ${'PORTS'.padEnd(14)}`;
370
+ buf.push(colHeader + RESET);
371
+ break;
372
+ }
373
+ case 'service': {
374
+ const i = stub.flatIdx;
375
+ const entry = state.flatList[i];
376
+ const sk = (0, state_1.statusKey)(entry.file, entry.service);
377
+ const st = state.statuses.get(sk);
378
+ const rebuilding = state.rebuilding.has(sk);
379
+ const restarting = state.restarting.has(sk);
380
+ const stopping = state.stopping.has(sk);
381
+ const starting = state.starting.has(sk);
382
+ const isWatching = state.watching.has(sk);
383
+ const isCascading = state.cascading.has(sk);
384
+ const icon = statusIcon(st, rebuilding || isCascading, restarting, stopping, starting);
385
+ const stext = statusText(st, rebuilding || isCascading, restarting, stopping, starting);
386
+ const watchIndicator = isWatching ? `${FG_CYAN}W${RESET}` : ' ';
387
+ const name = entry.service.padEnd(24);
388
+ const statusPadded = padVisible(stext, 22);
389
+ let cpuMemStr;
390
+ const stats = state.containerStats ? state.containerStats.get(sk) : null;
391
+ if (stats && st && st.state === 'running') {
392
+ const cpu = stats.cpuPercent;
393
+ const mem = stats.memUsageBytes;
394
+ const cpuWarn = state.config.cpuWarnThreshold || 50;
395
+ const cpuDanger = state.config.cpuDangerThreshold || 100;
396
+ const memWarn = (state.config.memWarnThreshold || 512) * 1024 * 1024;
397
+ const memDanger = (state.config.memDangerThreshold || 1024) * 1024 * 1024;
398
+ let color = DIM;
399
+ if (cpu > cpuDanger || mem > memDanger)
400
+ color = FG_RED;
401
+ else if (cpu > cpuWarn || mem > memWarn)
402
+ color = FG_YELLOW;
403
+ const cpuText = cpu.toFixed(1) + '%';
404
+ const memText = formatMem(mem);
405
+ cpuMemStr = padVisible(`${color}${cpuText} / ${memText}${RESET}`, 16);
406
+ }
407
+ else {
408
+ cpuMemStr = padVisible(`${DIM}-${RESET}`, 16);
409
+ }
410
+ let portsStr;
411
+ if (st && st.ports && st.ports.length > 0) {
412
+ const portsText = st.ports.map(p => p.published).join(' ');
413
+ portsStr = padVisible(`${DIM}${portsText}${RESET}`, 14);
414
+ }
415
+ else {
416
+ portsStr = padVisible(`${DIM}-${RESET}`, 14);
417
+ }
418
+ const built = padVisible(relativeTime(st ? st.createdAt : null), 12);
419
+ const restarted = padVisible(relativeTime(st ? st.startedAt : null), 12);
420
+ const pointer = i === state.cursor ? `${REVERSE}` : '';
421
+ const endPointer = i === state.cursor ? `${RESET}` : '';
422
+ let countsStr = '';
423
+ const logCounts = state.logCounts.get(sk);
424
+ for (let pi = 0; pi < patterns.length; pi++) {
425
+ const count = logCounts ? (logCounts.get(patterns[pi]) || 0) : 0;
426
+ const color = count > 0 ? PATTERN_COLORS[pi % PATTERN_COLORS.length] : DIM;
427
+ const countText = count > 0 ? `${color}${count}${RESET}` : `${color}-${RESET}`;
428
+ countsStr += padVisibleStart(countText, 5) + ' ';
429
+ }
430
+ buf.push(`${pointer} ${watchIndicator}${icon} ${FG_WHITE}${name}${RESET} ${statusPadded} ${built} ${restarted}${countsStr} ${cpuMemStr} ${portsStr}${endPointer}`);
431
+ break;
432
+ }
433
+ }
388
434
  }
389
435
  const usedLines = buf.length + bottomHeight;
390
436
  const paddingNeeded = Math.max(0, rows - usedLines);
@@ -392,7 +438,7 @@ function renderListView(state) {
392
438
  buf.push('');
393
439
  }
394
440
  buf.push(...bottomBuf);
395
- return buf.join('\n');
441
+ return buf.join(exports.CLEAR_EOL + '\n');
396
442
  }
397
443
  function truncateLine(str, maxWidth) {
398
444
  let visPos = 0;
@@ -418,11 +464,12 @@ function truncateLine(str, maxWidth) {
418
464
  }
419
465
  return str;
420
466
  }
421
- function highlightSearchInLine(line, query) {
467
+ function highlightSearchInLine(line, query, baseColor) {
422
468
  if (!query)
423
469
  return line;
424
470
  const lowerLine = line.toLowerCase();
425
471
  const lowerQuery = query.toLowerCase();
472
+ const restore = baseColor || '';
426
473
  let result = '';
427
474
  let pos = 0;
428
475
  while (pos < line.length) {
@@ -432,11 +479,20 @@ function highlightSearchInLine(line, query) {
432
479
  break;
433
480
  }
434
481
  result += line.substring(pos, idx);
435
- result += `${REVERSE}${FG_YELLOW}${line.substring(idx, idx + query.length)}${RESET}`;
482
+ result += `${REVERSE}${FG_YELLOW}${line.substring(idx, idx + query.length)}${RESET}${restore}`;
436
483
  pos = idx + query.length;
437
484
  }
438
485
  return result;
439
486
  }
487
+ function wrapPlainLine(line, width) {
488
+ if (width <= 0 || line.length <= width)
489
+ return [line];
490
+ const result = [];
491
+ for (let i = 0; i < line.length; i += width) {
492
+ result.push(line.substring(i, i + width));
493
+ }
494
+ return result;
495
+ }
440
496
  function renderLogView(state) {
441
497
  const columns = process.stdout.columns ?? 80;
442
498
  const rows = process.stdout.rows ?? 24;
@@ -444,17 +500,43 @@ function renderLogView(state) {
444
500
  for (const line of LOGO) {
445
501
  buf.push(line);
446
502
  }
447
- buf.push(` ${FG_GRAY}${'\u2500'.repeat(Math.max(0, columns - 2))}${RESET}`);
448
- buf.push(` ${renderLegend({ logsScrollMode: true })}`);
503
+ buf.push(separatorLine(columns));
504
+ const hasLogSearch = !!state.logSearchQuery && !state.logSearchActive;
505
+ buf.push(` ${renderLegend({ logsScrollMode: true, hasLogSearch })}`);
449
506
  const entry = state.flatList[state.cursor];
450
507
  const serviceName = entry ? entry.service : '???';
451
508
  const totalLines = state.logLines.length;
452
- let statusLine = ` ${FG_GREEN}full logs ${BOLD}${serviceName}${RESET}`;
453
- const scrollStatus = state.logAutoScroll
454
- ? `${FG_GREEN}live${RESET}`
455
- : `${FG_YELLOW}paused ${DIM}line ${Math.max(1, totalLines - state.logScrollOffset)} / ${totalLines}${RESET}`;
509
+ let statusLine;
510
+ if (state.logBuildKey) {
511
+ const buildInfo = state.bottomLogLines.get(state.logBuildKey);
512
+ const isBuilding = state.rebuilding.has(state.logBuildKey) || state.cascading.has(state.logBuildKey);
513
+ if (buildInfo && buildInfo.action === 'build_failed') {
514
+ statusLine = ` ${FG_RED}build failed ${BOLD}${serviceName}${RESET}`;
515
+ }
516
+ else if (isBuilding) {
517
+ statusLine = ` ${FG_YELLOW}rebuilding ${BOLD}${serviceName}${RESET}`;
518
+ }
519
+ else {
520
+ statusLine = ` ${FG_GREEN}build logs ${BOLD}${serviceName}${RESET}`;
521
+ }
522
+ }
523
+ else {
524
+ statusLine = ` ${FG_GREEN}full logs ${BOLD}${serviceName}${RESET}`;
525
+ }
526
+ let scrollStatus;
527
+ if (state.logAutoScroll) {
528
+ scrollStatus = `${FG_GREEN}live${RESET}`;
529
+ }
530
+ else {
531
+ const currentLine = Math.max(1, totalLines - state.logScrollOffset);
532
+ const pct = totalLines > 0 ? Math.round((currentLine / totalLines) * 100) : 100;
533
+ scrollStatus = `${FG_YELLOW}paused ${DIM}line ${currentLine} / ${totalLines} (${pct}%)${RESET}`;
534
+ }
456
535
  statusLine += ` ${scrollStatus}`;
457
- if (state.logSearchQuery && state.logSearchMatches.length > 0) {
536
+ if (state.logSearchPending || state.logHistoryLoading) {
537
+ statusLine += ` ${FG_YELLOW}loading history...${RESET}`;
538
+ }
539
+ else if (state.logSearchQuery && state.logSearchMatches.length > 0) {
458
540
  statusLine += ` ${DIM}match ${state.logSearchMatchIdx + 1}/${state.logSearchMatches.length}${RESET}`;
459
541
  }
460
542
  else if (state.logSearchQuery && state.logSearchMatches.length === 0) {
@@ -469,18 +551,38 @@ function renderLogView(state) {
469
551
  endLine = totalLines;
470
552
  }
471
553
  else {
472
- endLine = Math.max(0, totalLines - state.logScrollOffset);
554
+ endLine = Math.max(Math.min(availableRows, totalLines), totalLines - state.logScrollOffset);
555
+ }
556
+ if (totalLines === 0) {
557
+ buf.push(` ${DIM}loading...${RESET}`);
473
558
  }
474
- const startLine = Math.max(0, endLine - availableRows);
475
559
  const searchQuery = state.logSearchQuery || '';
476
560
  const matchSet = searchQuery ? new Set(state.logSearchMatches) : null;
477
- for (let i = startLine; i < endLine; i++) {
478
- let line = state.logLines[i];
479
- if (matchSet && matchSet.has(i)) {
480
- line = highlightSearchInLine(line, searchQuery);
561
+ const patterns = state.config.logScanPatterns || [];
562
+ // Build display lines by wrapping log lines, working backwards from endLine
563
+ const displayLines = [];
564
+ for (let i = endLine - 1; i >= 0 && displayLines.length < availableRows; i--) {
565
+ const line = state.logLines[i];
566
+ const wrapped = wrapPlainLine(line, columns);
567
+ const isMatch = matchSet && matchSet.has(i);
568
+ const lineColor = logLineColor(line, patterns);
569
+ for (let w = wrapped.length - 1; w >= 0; w--) {
570
+ let segment = wrapped[w];
571
+ if (isMatch) {
572
+ segment = highlightSearchInLine(segment, searchQuery, lineColor || undefined);
573
+ }
574
+ if (lineColor) {
575
+ segment = `${lineColor}${segment}${RESET}`;
576
+ }
577
+ displayLines.push(segment);
481
578
  }
482
- buf.push(truncateLine(line, columns));
483
579
  }
580
+ displayLines.reverse();
581
+ // Trim to fit available rows (keep the bottom portion)
582
+ const trimmed = displayLines.length > availableRows
583
+ ? displayLines.slice(displayLines.length - availableRows)
584
+ : displayLines;
585
+ buf.push(...trimmed);
484
586
  const targetRows = rows - bottomReserved;
485
587
  for (let i = buf.length; i < targetRows; i++) {
486
588
  buf.push('');
@@ -488,7 +590,7 @@ function renderLogView(state) {
488
590
  if (state.logSearchActive) {
489
591
  buf.push(`${BOLD}/${RESET}${state.logSearchQuery}${BOLD}_${RESET}`);
490
592
  }
491
- return buf.join('\n');
593
+ return buf.join(exports.CLEAR_EOL + '\n');
492
594
  }
493
595
  function renderExecView(state) {
494
596
  const columns = process.stdout.columns ?? 80;
@@ -497,7 +599,7 @@ function renderExecView(state) {
497
599
  for (const line of LOGO) {
498
600
  buf.push(line);
499
601
  }
500
- buf.push(` ${FG_GRAY}${'\u2500'.repeat(Math.max(0, columns - 2))}${RESET}`);
602
+ buf.push(separatorLine(columns));
501
603
  buf.push(` ${renderLegend({ execMode: true })}`);
502
604
  const serviceName = state.execService || '???';
503
605
  const runningIndicator = state.execChild ? `${FG_YELLOW}running${RESET}` : `${FG_GREEN}ready${RESET}`;
@@ -519,6 +621,6 @@ function renderExecView(state) {
519
621
  }
520
622
  // Command prompt
521
623
  buf.push(`${FG_GREEN}$ ${RESET}${state.execInput}${BOLD}_${RESET}`);
522
- return buf.join('\n');
624
+ return buf.join(exports.CLEAR_EOL + '\n');
523
625
  }
524
626
  //# sourceMappingURL=renderer.js.map