recomposable 1.1.1 → 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");
@@ -33,9 +35,7 @@ const LOGO = [
33
35
  ` ${ITALIC}${BOLD}${FG_CYAN}\u250C\u2500\u2510\u250C\u2500\u2510\u250C\u2500\u2510\u250C\u2500\u2510\u250C\u252C\u2510\u250C\u2500\u2510\u250C\u2500\u2510\u250C\u2500\u2510\u250C\u2500\u2510\u250C\u2510 \u252C \u250C\u2500\u2510${RESET}`,
34
36
  ` ${ITALIC}${BOLD}${FG_CYAN}\u251C\u252C\u2518\u251C\u2524 \u2502 \u2502 \u2502\u2502\u2502\u2502\u251C\u2500\u2518\u2502 \u2502\u2514\u2500\u2510\u251C\u2500\u2524\u251C\u2534\u2510\u2502 \u251C\u2524${RESET}`,
35
37
  ` ${ITALIC}${BOLD}${FG_CYAN}\u2534\u2514\u2500\u2514\u2500\u2518\u2514\u2500\u2518\u2514\u2500\u2518\u2534 \u2534\u2534 \u2514\u2500\u2518\u2514\u2500\u2518\u2534 \u2534\u2514\u2500\u2518\u2534\u2500\u2518\u2514\u2500\u2518${RESET}`,
36
- ``,
37
38
  ` ${DIM}docker compose manager${RESET}`,
38
- ``,
39
39
  ];
40
40
  function visLen(str) {
41
41
  return str.replace(/\x1b\[[0-9;]*m/g, '').length;
@@ -48,7 +48,21 @@ function padVisibleStart(str, width) {
48
48
  const pad = Math.max(0, width - visLen(str));
49
49
  return ' '.repeat(pad) + str;
50
50
  }
51
+ exports.CLEAR_EOL = `${ESC}K`;
52
+ exports.CLEAR_EOS = `${ESC}J`;
51
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 = '';
52
66
  function patternLabel(pattern) {
53
67
  return pattern.replace(/^[\[\(\{<]/, '').replace(/[\]\)\}>]$/, '');
54
68
  }
@@ -78,7 +92,14 @@ function relativeTime(ts) {
78
92
  return `${DIM}${days}d ago${RESET}`;
79
93
  }
80
94
  function clearScreen() {
81
- 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;
82
103
  }
83
104
  function showCursor() {
84
105
  return `${ESC}?25h`;
@@ -135,7 +156,7 @@ function formatMem(bytes) {
135
156
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}G`;
136
157
  }
137
158
  function renderLegend(opts = {}) {
138
- 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;
139
160
  const item = (text, active) => {
140
161
  if (active)
141
162
  return `${BG_HIGHLIGHT} ${text} ${RESET}`;
@@ -161,8 +182,9 @@ function renderLegend(opts = {}) {
161
182
  ].join(' ');
162
183
  }
163
184
  if (logsScrollMode) {
185
+ const hasSearch = opts.hasLogSearch || false;
164
186
  return [
165
- item('[Esc] back', false),
187
+ item(hasSearch ? '[Esc] clear search' : '[Esc] back', false),
166
188
  item('[j/k] scroll', false),
167
189
  item('[G] bottom', false),
168
190
  item('[gg] top', false),
@@ -178,6 +200,7 @@ function renderLegend(opts = {}) {
178
200
  item('Sto[P]', false),
179
201
  item('[W]atch', watchActive),
180
202
  item('[N]o cache', noCacheActive),
203
+ item('n[O] deps', noDepsActive),
181
204
  item('[e]xec', false),
182
205
  item('[F]ull logs', false),
183
206
  item('[L]og panel', logPanelActive),
@@ -188,6 +211,7 @@ function renderListView(state) {
188
211
  const columns = process.stdout.columns ?? 80;
189
212
  const rows = process.stdout.rows ?? 24;
190
213
  const patterns = state.config.logScanPatterns || [];
214
+ const sep = separatorLine(columns);
191
215
  const buf = [];
192
216
  for (const line of LOGO) {
193
217
  buf.push(line);
@@ -195,13 +219,13 @@ function renderListView(state) {
195
219
  const watchActive = state.watching.size > 0;
196
220
  const help = state.execActive
197
221
  ? renderLegend({ execInline: true })
198
- : renderLegend({ logPanelActive: state.showBottomLogs, noCacheActive: state.noCache, watchActive });
199
- 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);
200
224
  buf.push(` ${help}`);
201
225
  const headerHeight = buf.length;
202
226
  const bottomBuf = [];
203
227
  if (state.execActive && state.execService) {
204
- bottomBuf.push(` ${FG_GRAY}${'\u2500'.repeat(Math.max(0, columns - 2))}${RESET}`);
228
+ bottomBuf.push(sep);
205
229
  const runningIndicator = state.execChild ? `${FG_YELLOW}running${RESET}` : `${FG_GREEN}ready${RESET}`;
206
230
  const cwdInfo = state.execCwd ? ` ${DIM}${state.execCwd}${RESET}` : '';
207
231
  bottomBuf.push(` ${FG_CYAN}exec ${BOLD}${state.execService}${RESET} ${runningIndicator}${cwdInfo}`);
@@ -219,7 +243,7 @@ function renderListView(state) {
219
243
  // Check for cascade progress
220
244
  const cascade = state.cascading.get(sk);
221
245
  if (cascade) {
222
- bottomBuf.push(` ${FG_GRAY}${'\u2500'.repeat(Math.max(0, columns - 2))}${RESET}`);
246
+ bottomBuf.push(sep);
223
247
  bottomBuf.push(` ${FG_YELLOW}cascading ${BOLD}${selEntry.service}${RESET}`);
224
248
  for (let si = 0; si < cascade.steps.length; si++) {
225
249
  const step = cascade.steps[si];
@@ -242,22 +266,36 @@ function renderListView(state) {
242
266
  const info = state.bottomLogLines.get(sk);
243
267
  if (info) {
244
268
  if (!cascade) {
245
- bottomBuf.push(` ${FG_GRAY}${'\u2500'.repeat(Math.max(0, columns - 2))}${RESET}`);
269
+ bottomBuf.push(sep);
246
270
  }
247
- const actionColor = info.action === 'rebuilding' || info.action === 'restarting' || info.action === 'stopping' || info.action === 'starting' || info.action === 'cascading' ? FG_YELLOW
248
- : info.action === 'watching' ? FG_CYAN : FG_GREEN;
249
- 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}`;
250
277
  const bq = state.bottomSearchQuery || '';
251
278
  if (bq && !state.bottomSearchActive) {
252
- const matchCount = info.lines.filter(l => l.toLowerCase().includes(bq.toLowerCase())).length;
253
- headerLine += matchCount > 0
254
- ? ` ${DIM}search: "${bq}" (${matchCount} match${matchCount !== 1 ? 'es' : ''})${RESET}`
255
- : ` ${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
+ }
256
288
  }
257
289
  bottomBuf.push(headerLine);
290
+ if (info.lines.length === 0 && info.action === 'logs') {
291
+ bottomBuf.push(` ${DIM}loading...${RESET}`);
292
+ }
258
293
  const searchQuery = bq && !state.bottomSearchActive ? bq : '';
259
- 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) {
260
297
  let coloredLine = line.substring(0, columns - 4);
298
+ const lineColor = logLineColor(coloredLine, patterns) || FG_GRAY;
261
299
  if (searchQuery) {
262
300
  const lowerLine = coloredLine.toLowerCase();
263
301
  const lowerQ = searchQuery.toLowerCase();
@@ -271,20 +309,13 @@ function renderListView(state) {
271
309
  break;
272
310
  }
273
311
  result += coloredLine.substring(pos, idx);
274
- 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}`;
275
313
  pos = idx + searchQuery.length;
276
314
  }
277
315
  coloredLine = result;
278
316
  }
279
317
  }
280
- for (let pi = 0; pi < patterns.length; pi++) {
281
- const p = patterns[pi];
282
- if (coloredLine.includes(p)) {
283
- const color = PATTERN_COLORS[pi % PATTERN_COLORS.length];
284
- coloredLine = coloredLine.split(p).join(`${color}${p}${RESET}${FG_GRAY}`);
285
- }
286
- }
287
- bottomBuf.push(` ${FG_GRAY}${coloredLine}${RESET}`);
318
+ bottomBuf.push(` ${lineColor}${coloredLine}${RESET}`);
288
319
  }
289
320
  if (state.bottomSearchActive) {
290
321
  bottomBuf.push(`${BOLD}/${RESET}${state.bottomSearchQuery}${BOLD}_${RESET}`);
@@ -293,100 +324,113 @@ function renderListView(state) {
293
324
  }
294
325
  }
295
326
  const bottomHeight = bottomBuf.length;
296
- const lines = [];
327
+ // Pass 1: build lightweight stubs (type + index only, no text computation)
328
+ const stubs = [];
297
329
  let currentGroup = -1;
298
330
  for (let i = 0; i < state.flatList.length; i++) {
299
331
  const entry = state.flatList[i];
300
332
  if (entry.groupIdx !== currentGroup) {
301
333
  currentGroup = entry.groupIdx;
302
- const group = state.groups[entry.groupIdx];
303
- if (lines.length > 0)
304
- lines.push({ type: 'blank', text: '' });
305
- const label = ` ${BOLD}${group.label}${RESET}`;
306
- if (group.error) {
307
- lines.push({ type: 'header', text: `${label} ${FG_RED}(${group.error})${RESET}` });
308
- }
309
- else {
310
- lines.push({ type: 'header', text: label });
311
- }
312
- let colHeader = `${DIM} ${'SERVICE'.padEnd(24)} ${'STATUS'.padEnd(22)} ${'BUILT'.padEnd(12)} ${'RESTARTED'.padEnd(12)}`;
313
- for (const p of patterns)
314
- colHeader += patternLabel(p).padStart(5) + ' ';
315
- colHeader += ` ${'CPU/MEM'.padStart(16)} ${'PORTS'.padEnd(14)}`;
316
- lines.push({ type: 'colheader', text: colHeader + RESET });
317
- }
318
- const sk = (0, state_1.statusKey)(entry.file, entry.service);
319
- const st = state.statuses.get(sk);
320
- const rebuilding = state.rebuilding.has(sk);
321
- const restarting = state.restarting.has(sk);
322
- const stopping = state.stopping.has(sk);
323
- const starting = state.starting.has(sk);
324
- const isWatching = state.watching.has(sk);
325
- const isCascading = state.cascading.has(sk);
326
- const icon = statusIcon(st, rebuilding || isCascading, restarting, stopping, starting);
327
- const stext = statusText(st, rebuilding || isCascading, restarting, stopping, starting);
328
- const watchIndicator = isWatching ? `${FG_CYAN}W${RESET}` : ' ';
329
- const name = entry.service.padEnd(24);
330
- const statusPadded = padVisible(stext, 22);
331
- let cpuMemStr;
332
- const stats = state.containerStats ? state.containerStats.get(sk) : null;
333
- if (stats && st && st.state === 'running') {
334
- const cpu = stats.cpuPercent;
335
- const mem = stats.memUsageBytes;
336
- const cpuWarn = state.config.cpuWarnThreshold || 50;
337
- const cpuDanger = state.config.cpuDangerThreshold || 100;
338
- const memWarn = (state.config.memWarnThreshold || 512) * 1024 * 1024;
339
- const memDanger = (state.config.memDangerThreshold || 1024) * 1024 * 1024;
340
- let color = DIM;
341
- if (cpu > cpuDanger || mem > memDanger)
342
- color = FG_RED;
343
- else if (cpu > cpuWarn || mem > memWarn)
344
- color = FG_YELLOW;
345
- const cpuText = cpu.toFixed(1) + '%';
346
- const memText = formatMem(mem);
347
- cpuMemStr = padVisible(`${color}${cpuText} / ${memText}${RESET}`, 16);
348
- }
349
- else {
350
- cpuMemStr = padVisible(`${DIM}-${RESET}`, 16);
351
- }
352
- let portsStr;
353
- if (st && st.ports && st.ports.length > 0) {
354
- const portsText = st.ports.map(p => p.published).join(' ');
355
- portsStr = padVisible(`${DIM}${portsText}${RESET}`, 14);
356
- }
357
- else {
358
- portsStr = padVisible(`${DIM}-${RESET}`, 14);
359
- }
360
- const built = padVisible(relativeTime(st ? st.createdAt : null), 12);
361
- const restarted = padVisible(relativeTime(st ? st.startedAt : null), 12);
362
- const pointer = i === state.cursor ? `${REVERSE}` : '';
363
- const endPointer = i === state.cursor ? `${RESET}` : '';
364
- let countsStr = '';
365
- const logCounts = state.logCounts.get(sk);
366
- for (let pi = 0; pi < patterns.length; pi++) {
367
- const count = logCounts ? (logCounts.get(patterns[pi]) || 0) : 0;
368
- const color = count > 0 ? PATTERN_COLORS[pi % PATTERN_COLORS.length] : DIM;
369
- const countText = count > 0 ? `${color}${count}${RESET}` : `${color}-${RESET}`;
370
- 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 });
371
338
  }
372
- lines.push({
373
- type: 'service',
374
- text: `${pointer} ${watchIndicator}${icon} ${FG_WHITE}${name}${RESET} ${statusPadded} ${built} ${restarted}${countsStr} ${cpuMemStr} ${portsStr}${endPointer}`,
375
- flatIdx: i,
376
- });
339
+ stubs.push({ type: 'service', flatIdx: i, groupIdx: entry.groupIdx });
377
340
  }
378
341
  const availableRows = Math.max(3, rows - headerHeight - bottomHeight);
379
- const cursorLineIdx = lines.findIndex(l => l.type === 'service' && l.flatIdx === state.cursor);
380
- if (cursorLineIdx < state.scrollOffset) {
381
- 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;
382
346
  }
383
- else if (cursorLineIdx >= state.scrollOffset + availableRows) {
384
- state.scrollOffset = cursorLineIdx - availableRows + 1;
347
+ else if (cursorStubIdx >= state.scrollOffset + availableRows) {
348
+ state.scrollOffset = cursorStubIdx - availableRows + 1;
385
349
  }
386
- state.scrollOffset = Math.max(0, Math.min(lines.length - availableRows, state.scrollOffset));
387
- const visible = lines.slice(state.scrollOffset, state.scrollOffset + availableRows);
388
- for (const line of visible) {
389
- 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
+ }
390
434
  }
391
435
  const usedLines = buf.length + bottomHeight;
392
436
  const paddingNeeded = Math.max(0, rows - usedLines);
@@ -394,7 +438,7 @@ function renderListView(state) {
394
438
  buf.push('');
395
439
  }
396
440
  buf.push(...bottomBuf);
397
- return buf.join('\n');
441
+ return buf.join(exports.CLEAR_EOL + '\n');
398
442
  }
399
443
  function truncateLine(str, maxWidth) {
400
444
  let visPos = 0;
@@ -420,11 +464,12 @@ function truncateLine(str, maxWidth) {
420
464
  }
421
465
  return str;
422
466
  }
423
- function highlightSearchInLine(line, query) {
467
+ function highlightSearchInLine(line, query, baseColor) {
424
468
  if (!query)
425
469
  return line;
426
470
  const lowerLine = line.toLowerCase();
427
471
  const lowerQuery = query.toLowerCase();
472
+ const restore = baseColor || '';
428
473
  let result = '';
429
474
  let pos = 0;
430
475
  while (pos < line.length) {
@@ -434,11 +479,20 @@ function highlightSearchInLine(line, query) {
434
479
  break;
435
480
  }
436
481
  result += line.substring(pos, idx);
437
- result += `${REVERSE}${FG_YELLOW}${line.substring(idx, idx + query.length)}${RESET}`;
482
+ result += `${REVERSE}${FG_YELLOW}${line.substring(idx, idx + query.length)}${RESET}${restore}`;
438
483
  pos = idx + query.length;
439
484
  }
440
485
  return result;
441
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
+ }
442
496
  function renderLogView(state) {
443
497
  const columns = process.stdout.columns ?? 80;
444
498
  const rows = process.stdout.rows ?? 24;
@@ -446,17 +500,43 @@ function renderLogView(state) {
446
500
  for (const line of LOGO) {
447
501
  buf.push(line);
448
502
  }
449
- buf.push(` ${FG_GRAY}${'\u2500'.repeat(Math.max(0, columns - 2))}${RESET}`);
450
- buf.push(` ${renderLegend({ logsScrollMode: true })}`);
503
+ buf.push(separatorLine(columns));
504
+ const hasLogSearch = !!state.logSearchQuery && !state.logSearchActive;
505
+ buf.push(` ${renderLegend({ logsScrollMode: true, hasLogSearch })}`);
451
506
  const entry = state.flatList[state.cursor];
452
507
  const serviceName = entry ? entry.service : '???';
453
508
  const totalLines = state.logLines.length;
454
- let statusLine = ` ${FG_GREEN}full logs ${BOLD}${serviceName}${RESET}`;
455
- const scrollStatus = state.logAutoScroll
456
- ? `${FG_GREEN}live${RESET}`
457
- : `${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
+ }
458
535
  statusLine += ` ${scrollStatus}`;
459
- 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) {
460
540
  statusLine += ` ${DIM}match ${state.logSearchMatchIdx + 1}/${state.logSearchMatches.length}${RESET}`;
461
541
  }
462
542
  else if (state.logSearchQuery && state.logSearchMatches.length === 0) {
@@ -471,18 +551,38 @@ function renderLogView(state) {
471
551
  endLine = totalLines;
472
552
  }
473
553
  else {
474
- 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}`);
475
558
  }
476
- const startLine = Math.max(0, endLine - availableRows);
477
559
  const searchQuery = state.logSearchQuery || '';
478
560
  const matchSet = searchQuery ? new Set(state.logSearchMatches) : null;
479
- for (let i = startLine; i < endLine; i++) {
480
- let line = state.logLines[i];
481
- if (matchSet && matchSet.has(i)) {
482
- 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);
483
578
  }
484
- buf.push(truncateLine(line, columns));
485
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);
486
586
  const targetRows = rows - bottomReserved;
487
587
  for (let i = buf.length; i < targetRows; i++) {
488
588
  buf.push('');
@@ -490,7 +590,7 @@ function renderLogView(state) {
490
590
  if (state.logSearchActive) {
491
591
  buf.push(`${BOLD}/${RESET}${state.logSearchQuery}${BOLD}_${RESET}`);
492
592
  }
493
- return buf.join('\n');
593
+ return buf.join(exports.CLEAR_EOL + '\n');
494
594
  }
495
595
  function renderExecView(state) {
496
596
  const columns = process.stdout.columns ?? 80;
@@ -499,7 +599,7 @@ function renderExecView(state) {
499
599
  for (const line of LOGO) {
500
600
  buf.push(line);
501
601
  }
502
- buf.push(` ${FG_GRAY}${'\u2500'.repeat(Math.max(0, columns - 2))}${RESET}`);
602
+ buf.push(separatorLine(columns));
503
603
  buf.push(` ${renderLegend({ execMode: true })}`);
504
604
  const serviceName = state.execService || '???';
505
605
  const runningIndicator = state.execChild ? `${FG_YELLOW}running${RESET}` : `${FG_GREEN}ready${RESET}`;
@@ -521,6 +621,6 @@ function renderExecView(state) {
521
621
  }
522
622
  // Command prompt
523
623
  buf.push(`${FG_GREEN}$ ${RESET}${state.execInput}${BOLD}_${RESET}`);
524
- return buf.join('\n');
624
+ return buf.join(exports.CLEAR_EOL + '\n');
525
625
  }
526
626
  //# sourceMappingURL=renderer.js.map