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