retold 4.0.2 → 4.0.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.
Files changed (34) hide show
  1. package/.claude/settings.local.json +37 -1
  2. package/README.md +112 -2
  3. package/docs/_sidebar.md +4 -1
  4. package/docs/architecture/architecture.md +2 -2
  5. package/docs/architecture/comprehensions.md +282 -0
  6. package/docs/architecture/fluid-models.md +355 -0
  7. package/docs/architecture/modules.md +3 -4
  8. package/docs/contributing.md +50 -0
  9. package/docs/modules/orator.md +0 -7
  10. package/docs/retold-catalog.json +3944 -878
  11. package/docs/retold-keyword-index.json +174010 -144308
  12. package/docs/testing.md +122 -0
  13. package/modules/Include-Retold-Module-List.sh +1 -1
  14. package/package.json +7 -4
  15. package/source/retold-manager/package.json +23 -0
  16. package/source/retold-manager/retold-manager.js +65 -0
  17. package/source/retold-manager/source/Retold-Manager-App.js +1532 -0
  18. package/source/retold-manager/source/Retold-Manager-ModuleCatalog.js +75 -0
  19. package/source/retold-manager/source/Retold-Manager-ProcessRunner.js +706 -0
  20. package/source/retold-manager/source/views/PictView-TUI-Checkout.js +45 -0
  21. package/source/retold-manager/source/views/PictView-TUI-Header.js +41 -0
  22. package/source/retold-manager/source/views/PictView-TUI-Layout.js +53 -0
  23. package/source/retold-manager/source/views/PictView-TUI-Status.js +45 -0
  24. package/source/retold-manager/source/views/PictView-TUI-StatusBar.js +41 -0
  25. package/source/retold-manager/source/views/PictView-TUI-Update.js +45 -0
  26. package/examples/quickstart/layer1/package-lock.json +0 -344
  27. package/examples/quickstart/layer2/package-lock.json +0 -4468
  28. package/examples/quickstart/layer3/package-lock.json +0 -1936
  29. package/examples/quickstart/layer4/package-lock.json +0 -13206
  30. package/examples/quickstart/layer5/package-lock.json +0 -345
  31. package/examples/todo-list/cli-client/package-lock.json +0 -418
  32. package/examples/todo-list/console-client/package-lock.json +0 -426
  33. package/examples/todo-list/server/package-lock.json +0 -6113
  34. package/examples/todo-list/web-client/package-lock.json +0 -12030
@@ -0,0 +1,706 @@
1
+ /**
2
+ * Retold Manager -- Process Runner
3
+ *
4
+ * Wraps child_process.spawn to run commands in a module directory and
5
+ * stream output to a blessed log widget.
6
+ */
7
+
8
+ const libChildProcess = require('child_process');
9
+
10
+ // Regex to strip ANSI escape codes (covers standard, 256-color, and truecolor sequences)
11
+ const ANSI_REGEX = /\x1B(?:\[[0-9;]*[a-zA-Z]|\].*?(?:\x07|\x1B\\)|\([B0])/g;
12
+
13
+ // Lines to show live at the start of a run before switching to buffer-only mode
14
+ const HEAD_LINE_LIMIT = 80;
15
+
16
+ // Minimum milliseconds between screen renders during streaming output
17
+ const RENDER_THROTTLE_MS = 50;
18
+
19
+ // Minimum milliseconds between status-bar updates while buffering
20
+ const STATUS_THROTTLE_MS = 250;
21
+
22
+ class ProcessRunner
23
+ {
24
+ /**
25
+ * @param {object} pLogWidget - A blessed.log widget instance.
26
+ * @param {object} pScreen - The blessed screen instance.
27
+ * @param {function} pStatusCallback - Called with (pState, pMessage) for status updates.
28
+ * @param {object} pLog - A fable-log instance for activity logging.
29
+ */
30
+ constructor(pLogWidget, pScreen, pStatusCallback, pLog)
31
+ {
32
+ this.logWidget = pLogWidget;
33
+ this.screen = pScreen;
34
+ this.statusCallback = pStatusCallback || function () {};
35
+ this.log = pLog || null;
36
+ this.activeProcess = null;
37
+
38
+ // Render throttle state
39
+ this._renderTimer = null;
40
+ this._renderPending = false;
41
+
42
+ // Output buffering: all lines go into _outputBuffer.
43
+ // The first HEAD_LINE_LIMIT lines are also sent live to the widget.
44
+ // After that, lines accumulate in the buffer only.
45
+ // On completion, the full buffer is loaded into the widget for scrolling.
46
+ this._outputBuffer = [];
47
+ this._headLineCount = 0;
48
+ this._buffering = false;
49
+
50
+ // Throttle for status-bar line-count updates while buffering
51
+ this._statusTimer = null;
52
+
53
+ // Timer for measuring operation duration
54
+ this._operationStartTime = null;
55
+ this._operationCommand = '';
56
+ }
57
+
58
+ /**
59
+ * Format elapsed milliseconds into a human-readable duration string.
60
+ * Examples: "1.2s", "1m 23s", "2h 5m 12s"
61
+ *
62
+ * @param {number} pMilliseconds - Elapsed time in milliseconds.
63
+ * @returns {string} Formatted duration.
64
+ */
65
+ _formatDuration(pMilliseconds)
66
+ {
67
+ let tmpSeconds = Math.floor(pMilliseconds / 1000);
68
+ let tmpMs = pMilliseconds % 1000;
69
+
70
+ if (tmpSeconds < 60)
71
+ {
72
+ return `${tmpSeconds}.${String(tmpMs).padStart(3, '0').slice(0, 1)}s`;
73
+ }
74
+
75
+ let tmpMinutes = Math.floor(tmpSeconds / 60);
76
+ tmpSeconds = tmpSeconds % 60;
77
+
78
+ if (tmpMinutes < 60)
79
+ {
80
+ return `${tmpMinutes}m ${tmpSeconds}s`;
81
+ }
82
+
83
+ let tmpHours = Math.floor(tmpMinutes / 60);
84
+ tmpMinutes = tmpMinutes % 60;
85
+ return `${tmpHours}h ${tmpMinutes}m ${tmpSeconds}s`;
86
+ }
87
+
88
+ /**
89
+ * Schedule a screen render, throttled to avoid hammering blessed
90
+ * on fast-streaming output.
91
+ */
92
+ _scheduleRender()
93
+ {
94
+ if (this._renderTimer)
95
+ {
96
+ this._renderPending = true;
97
+ return;
98
+ }
99
+
100
+ this.screen.render();
101
+
102
+ this._renderTimer = setTimeout(() =>
103
+ {
104
+ this._renderTimer = null;
105
+ if (this._renderPending)
106
+ {
107
+ this._renderPending = false;
108
+ this.screen.render();
109
+ }
110
+ }, RENDER_THROTTLE_MS);
111
+ }
112
+
113
+ /**
114
+ * Buffer a line and, if we are still in the head phase, also send it
115
+ * to the widget for live display. Once HEAD_LINE_LIMIT is reached
116
+ * we switch to buffer-only mode and periodically update the status bar
117
+ * with the running line count.
118
+ *
119
+ * @param {string} pLine - The already-escaped line to log.
120
+ */
121
+ _logLine(pLine)
122
+ {
123
+ this._outputBuffer.push(pLine);
124
+
125
+ if (!this._buffering)
126
+ {
127
+ // Still in the live-output head phase
128
+ this._headLineCount++;
129
+ this.logWidget.log(pLine);
130
+ this._scheduleRender();
131
+
132
+ if (this._headLineCount >= HEAD_LINE_LIMIT)
133
+ {
134
+ this._buffering = true;
135
+ this.logWidget.log('');
136
+ this.logWidget.log('{yellow-fg}{bold}... buffering remaining output (scrollable when complete){/bold}{/yellow-fg}');
137
+ this._scheduleRender();
138
+ }
139
+ }
140
+ else
141
+ {
142
+ // Buffer-only mode -- no widget writes, no screen renders.
143
+ // Just update the status bar periodically so the user sees progress.
144
+ if (!this._statusTimer)
145
+ {
146
+ this._statusTimer = setTimeout(() =>
147
+ {
148
+ this._statusTimer = null;
149
+ this.statusCallback('running', `${this._operationCommand} (${this._outputBuffer.length} lines)`);
150
+ }, STATUS_THROTTLE_MS);
151
+ }
152
+ }
153
+ }
154
+
155
+ /**
156
+ * After the process exits, load the full output buffer into the widget
157
+ * so the user can scroll through everything. Uses a single setContent()
158
+ * call to avoid per-line rendering overhead.
159
+ */
160
+ _flushBuffer()
161
+ {
162
+ if (!this._buffering)
163
+ {
164
+ // Everything was already displayed live -- nothing to flush
165
+ return;
166
+ }
167
+
168
+ // Build the full content as one string and set it in a single call
169
+ this.logWidget.setContent(this._outputBuffer.join('\n'));
170
+
171
+ // Scroll to the bottom so the user sees the tail / completion block
172
+ this.logWidget.setScrollPerc(100);
173
+ }
174
+
175
+ /**
176
+ * Run a command in the given working directory.
177
+ * Kills any currently running process first.
178
+ *
179
+ * Output strategy: the first HEAD_LINE_LIMIT lines are displayed live.
180
+ * After that, lines buffer in memory (no widget pressure). When the
181
+ * process exits the full buffer is loaded into the widget for scrolling.
182
+ *
183
+ * @param {string} pCommand - The command to run (e.g. 'npm', 'git').
184
+ * @param {Array} pArgs - Arguments array (e.g. ['test']).
185
+ * @param {string} pCwd - Working directory for the command.
186
+ * @param {number} pLineLimit - (ignored, kept for API compat).
187
+ * @param {object} pOptions - Optional settings: { append: true } to keep existing widget content.
188
+ */
189
+ run(pCommand, pArgs, pCwd, pLineLimit, pOptions)
190
+ {
191
+ // Kill any running process first
192
+ this.kill();
193
+
194
+ // Reset buffer state for this run
195
+ this._outputBuffer = [];
196
+ this._headLineCount = 0;
197
+ this._buffering = false;
198
+ if (this._statusTimer)
199
+ {
200
+ clearTimeout(this._statusTimer);
201
+ this._statusTimer = null;
202
+ }
203
+
204
+ // Clear any active search state so the new output is not filtered
205
+ this._searchQuery = '';
206
+ this._searchMatches = null;
207
+ this._searchMatchIndex = -1;
208
+ this._searchResultLines = null;
209
+
210
+ // Clear the log widget unless caller asked to append
211
+ if (!pOptions || !pOptions.append)
212
+ {
213
+ this.logWidget.setContent('');
214
+ }
215
+
216
+ let tmpCommandString = pCommand + ' ' + pArgs.join(' ');
217
+ let tmpRunnable = `cd ${pCwd} && ${tmpCommandString}`;
218
+
219
+ this.logWidget.log(`{bold}{cyan-fg}$ ${tmpCommandString}{/cyan-fg}{/bold}`);
220
+ this.logWidget.log(`{gray-fg} cwd: ${pCwd}{/gray-fg}`);
221
+ this.logWidget.log(`{gray-fg} run: ${tmpRunnable}{/gray-fg}`);
222
+ this.logWidget.log('');
223
+
224
+ this.statusCallback('running', tmpCommandString);
225
+
226
+ // Activity log: record the start time
227
+ this._operationCommand = tmpCommandString;
228
+ this._operationCwd = pCwd;
229
+ this._operationStartTime = this.log ? this.log.getTimeStamp() : Date.now();
230
+ if (this.log)
231
+ {
232
+ this.log.info(`START ${tmpRunnable}`);
233
+ }
234
+
235
+ let tmpProcess;
236
+
237
+ try
238
+ {
239
+ tmpProcess = libChildProcess.spawn(pCommand, pArgs,
240
+ {
241
+ cwd: pCwd,
242
+ shell: true,
243
+ env: Object.assign({}, process.env, { NO_COLOR: '1', FORCE_COLOR: '0' })
244
+ });
245
+ }
246
+ catch (pError)
247
+ {
248
+ this.logWidget.log(`{red-fg}{bold}Failed to start process: ${pError.message}{/red-fg}{/bold}`);
249
+ this.statusCallback('error', pError.message);
250
+ this.screen.render();
251
+ return;
252
+ }
253
+
254
+ this.activeProcess = tmpProcess;
255
+
256
+ tmpProcess.stdout.on('data', (pData) =>
257
+ {
258
+ let tmpLines = pData.toString().split('\n');
259
+ for (let i = 0; i < tmpLines.length; i++)
260
+ {
261
+ if (tmpLines[i].length > 0)
262
+ {
263
+ // Strip ANSI codes then escape curly braces so blessed
264
+ // doesn't try to parse them as markup tags
265
+ let tmpLine = tmpLines[i].replace(ANSI_REGEX, '').replace(/\{/g, '\\{').replace(/\}/g, '\\}');
266
+ this._logLine(tmpLine);
267
+ }
268
+ }
269
+ });
270
+
271
+ tmpProcess.stderr.on('data', (pData) =>
272
+ {
273
+ let tmpLines = pData.toString().split('\n');
274
+ for (let i = 0; i < tmpLines.length; i++)
275
+ {
276
+ if (tmpLines[i].length > 0)
277
+ {
278
+ let tmpLine = tmpLines[i].replace(ANSI_REGEX, '').replace(/\{/g, '\\{').replace(/\}/g, '\\}');
279
+ this._logLine(`{red-fg}${tmpLine}{/red-fg}`);
280
+ }
281
+ }
282
+ });
283
+
284
+ tmpProcess.on('close', (pCode) =>
285
+ {
286
+ this.activeProcess = null;
287
+
288
+ // Compute elapsed time
289
+ let tmpElapsed = this._operationStartTime ? (Date.now() - this._operationStartTime) : 0;
290
+ let tmpDuration = this._formatDuration(tmpElapsed);
291
+
292
+ // Activity log: record the result and duration
293
+ if (this.log && this._operationStartTime)
294
+ {
295
+ let tmpState = (pCode === 0) ? 'OK' : `FAIL(${pCode})`;
296
+ this.log.logTimeDeltaRelativeHuman(this._operationStartTime, `${tmpState} ${tmpRunnable}`);
297
+ this._operationStartTime = null;
298
+ }
299
+
300
+ // Flush the full buffer into the widget so the user can scroll
301
+ this._flushBuffer();
302
+
303
+ // Consistent completion block
304
+ this.logWidget.log('');
305
+ this.logWidget.log('{bold}────────────────────────────────────────{/bold}');
306
+ let tmpLineNote = this._outputBuffer.length > HEAD_LINE_LIMIT
307
+ ? ` {gray-fg}(${this._outputBuffer.length} lines){/gray-fg}`
308
+ : '';
309
+ if (pCode === 0)
310
+ {
311
+ this.logWidget.log(`{green-fg}{bold}✓ Done{/bold} ${tmpCommandString} ({bold}${tmpDuration}{/bold}){/green-fg}${tmpLineNote}`);
312
+ this.statusCallback('success', `${tmpCommandString} -- ${tmpDuration}`);
313
+ }
314
+ else
315
+ {
316
+ this.logWidget.log(`{red-fg}{bold}✗ Failed (exit ${pCode}){/bold} ${tmpCommandString} ({bold}${tmpDuration}{/bold}){/red-fg}${tmpLineNote}`);
317
+ this.statusCallback('error', `${tmpCommandString} -- exit ${pCode} (${tmpDuration})`);
318
+ }
319
+ this.logWidget.setScrollPerc(100);
320
+ this.screen.render();
321
+ });
322
+
323
+ tmpProcess.on('error', (pError) =>
324
+ {
325
+ this.activeProcess = null;
326
+ let tmpElapsed = this._operationStartTime ? (Date.now() - this._operationStartTime) : 0;
327
+ let tmpDuration = this._formatDuration(tmpElapsed);
328
+ if (this.log && this._operationStartTime)
329
+ {
330
+ this.log.logTimeDeltaRelativeHuman(this._operationStartTime, `ERROR ${tmpRunnable} ${pError.message}`);
331
+ this._operationStartTime = null;
332
+ }
333
+ this.logWidget.log('');
334
+ this.logWidget.log('{bold}────────────────────────────────────────{/bold}');
335
+ this.logWidget.log(`{red-fg}{bold}✗ Error{/bold} ${pError.message} ({bold}${tmpDuration}{/bold}){/red-fg}`);
336
+ this.statusCallback('error', `${pError.message} (${tmpDuration})`);
337
+ this.screen.render();
338
+ });
339
+ }
340
+
341
+ /**
342
+ * Run a sequence of commands in order, appending output from each
343
+ * into the same log widget with separator headers between them.
344
+ *
345
+ * @param {Array} pCommands - Array of { command, args, label } objects.
346
+ * @param {string} pCwd - Working directory for all commands.
347
+ */
348
+ runSequence(pCommands, pCwd)
349
+ {
350
+ // Kill any running process first
351
+ this.kill();
352
+
353
+ // Reset buffer state for this sequence
354
+ this._outputBuffer = [];
355
+ this._headLineCount = 0;
356
+ this._buffering = false;
357
+ if (this._statusTimer)
358
+ {
359
+ clearTimeout(this._statusTimer);
360
+ this._statusTimer = null;
361
+ }
362
+
363
+ // Clear any active search state so the new output is not filtered
364
+ this._searchQuery = '';
365
+ this._searchMatches = null;
366
+ this._searchMatchIndex = -1;
367
+ this._searchResultLines = null;
368
+
369
+ // Clear the log widget
370
+ this.logWidget.setContent('');
371
+
372
+ this.statusCallback('running', pCommands[0].label || (pCommands[0].command + ' ' + pCommands[0].args.join(' ')));
373
+
374
+ let tmpRunIndex = 0;
375
+ let tmpSelf = this;
376
+ let tmpSequenceStartTime = Date.now();
377
+
378
+ let fRunNext = function ()
379
+ {
380
+ if (tmpRunIndex >= pCommands.length)
381
+ {
382
+ let tmpTotalElapsed = Date.now() - tmpSequenceStartTime;
383
+ let tmpTotalDuration = tmpSelf._formatDuration(tmpTotalElapsed);
384
+ tmpSelf.logWidget.log('');
385
+ tmpSelf.logWidget.log('{bold}────────────────────────────────────────{/bold}');
386
+ tmpSelf.logWidget.log(`{green-fg}{bold}✓ Done{/bold} ${pCommands.length} commands ({bold}${tmpTotalDuration}{/bold}){/green-fg}`);
387
+ tmpSelf.statusCallback('success', `Sequence complete -- ${tmpTotalDuration}`);
388
+ tmpSelf.screen.render();
389
+ return;
390
+ }
391
+
392
+ let tmpCmd = pCommands[tmpRunIndex];
393
+ let tmpCommandString = tmpCmd.command + ' ' + tmpCmd.args.join(' ');
394
+ let tmpRunnable = `cd ${pCwd} && ${tmpCommandString}`;
395
+
396
+ if (tmpRunIndex > 0)
397
+ {
398
+ tmpSelf.logWidget.log('');
399
+ tmpSelf.logWidget.log('{bold}{blue-fg}────────────────────────────────────────{/blue-fg}{/bold}');
400
+ tmpSelf.logWidget.log('');
401
+ }
402
+
403
+ if (tmpCmd.label)
404
+ {
405
+ tmpSelf.logWidget.log(`{bold}{yellow-fg}${tmpCmd.label}{/yellow-fg}{/bold}`);
406
+ }
407
+ tmpSelf.logWidget.log(`{bold}{cyan-fg}$ ${tmpCommandString}{/cyan-fg}{/bold}`);
408
+ tmpSelf.logWidget.log(`{gray-fg} cwd: ${pCwd}{/gray-fg}`);
409
+ tmpSelf.logWidget.log(`{gray-fg} run: ${tmpRunnable}{/gray-fg}`);
410
+ tmpSelf.logWidget.log('');
411
+ tmpSelf.screen.render();
412
+
413
+ // Activity log: record the step start time
414
+ let tmpStepStartTime = tmpSelf.log ? tmpSelf.log.getTimeStamp() : Date.now();
415
+ if (tmpSelf.log)
416
+ {
417
+ tmpSelf.log.info(`START ${tmpRunnable}`);
418
+ }
419
+
420
+ let tmpProcess;
421
+
422
+ try
423
+ {
424
+ tmpProcess = libChildProcess.spawn(tmpCmd.command, tmpCmd.args,
425
+ {
426
+ cwd: pCwd,
427
+ shell: true,
428
+ env: Object.assign({}, process.env, { NO_COLOR: '1', FORCE_COLOR: '0' })
429
+ });
430
+ }
431
+ catch (pError)
432
+ {
433
+ tmpSelf.logWidget.log(`{red-fg}{bold}Failed to start: ${pError.message}{/red-fg}{/bold}`);
434
+ tmpSelf.statusCallback('error', pError.message);
435
+ tmpSelf.screen.render();
436
+ return;
437
+ }
438
+
439
+ tmpSelf.activeProcess = tmpProcess;
440
+
441
+ tmpProcess.stdout.on('data', (pData) =>
442
+ {
443
+ let tmpLines = pData.toString().split('\n');
444
+ for (let i = 0; i < tmpLines.length; i++)
445
+ {
446
+ if (tmpLines[i].length > 0)
447
+ {
448
+ let tmpLine = tmpLines[i].replace(ANSI_REGEX, '').replace(/\{/g, '\\{').replace(/\}/g, '\\}');
449
+ tmpSelf._logLine(tmpLine);
450
+ }
451
+ }
452
+ });
453
+
454
+ tmpProcess.stderr.on('data', (pData) =>
455
+ {
456
+ let tmpLines = pData.toString().split('\n');
457
+ for (let i = 0; i < tmpLines.length; i++)
458
+ {
459
+ if (tmpLines[i].length > 0)
460
+ {
461
+ let tmpLine = tmpLines[i].replace(ANSI_REGEX, '').replace(/\{/g, '\\{').replace(/\}/g, '\\}');
462
+ tmpSelf._logLine(`{red-fg}${tmpLine}{/red-fg}`);
463
+ }
464
+ }
465
+ });
466
+
467
+ tmpProcess.on('close', (pCode) =>
468
+ {
469
+ tmpSelf.activeProcess = null;
470
+
471
+ // Compute step elapsed time
472
+ let tmpStepElapsed = tmpStepStartTime ? (Date.now() - tmpStepStartTime) : 0;
473
+ let tmpStepDuration = tmpSelf._formatDuration(tmpStepElapsed);
474
+
475
+ if (tmpSelf.log && tmpStepStartTime)
476
+ {
477
+ let tmpState = (pCode === 0) ? 'OK' : `FAIL(${pCode})`;
478
+ tmpSelf.log.logTimeDeltaRelativeHuman(tmpStepStartTime, `${tmpState} ${tmpRunnable}`);
479
+ }
480
+
481
+ // Per-step completion line
482
+ if (pCode === 0)
483
+ {
484
+ tmpSelf.logWidget.log(`{green-fg} ✓ ${tmpCommandString} ({bold}${tmpStepDuration}{/bold}){/green-fg}`);
485
+ }
486
+ else
487
+ {
488
+ tmpSelf.logWidget.log(`{red-fg} ✗ ${tmpCommandString} exit ${pCode} ({bold}${tmpStepDuration}{/bold}){/red-fg}`);
489
+ }
490
+
491
+ tmpRunIndex++;
492
+ fRunNext();
493
+ });
494
+
495
+ tmpProcess.on('error', (pError) =>
496
+ {
497
+ tmpSelf.activeProcess = null;
498
+ if (tmpSelf.log && tmpStepStartTime)
499
+ {
500
+ tmpSelf.log.logTimeDeltaRelativeHuman(tmpStepStartTime, `ERROR ${tmpRunnable} ${pError.message}`);
501
+ }
502
+ tmpSelf.logWidget.log(`{red-fg}{bold}Failed: ${pError.message}{/red-fg}{/bold}`);
503
+ tmpSelf.statusCallback('error', pError.message);
504
+ tmpSelf.screen.render();
505
+ });
506
+ };
507
+
508
+ fRunNext();
509
+ }
510
+
511
+ /**
512
+ * Kill the currently running process, if any.
513
+ */
514
+ kill()
515
+ {
516
+ if (this._statusTimer)
517
+ {
518
+ clearTimeout(this._statusTimer);
519
+ this._statusTimer = null;
520
+ }
521
+ if (this.activeProcess)
522
+ {
523
+ this.activeProcess.kill('SIGTERM');
524
+ this.activeProcess = null;
525
+ }
526
+ }
527
+
528
+ /**
529
+ * @returns {boolean} Whether a process is currently running.
530
+ */
531
+ isRunning()
532
+ {
533
+ return this.activeProcess !== null;
534
+ }
535
+
536
+ /**
537
+ * Search the output buffer for lines matching a query string.
538
+ * Replaces the widget content with matching lines (with line numbers)
539
+ * and stores match indices for next/prev navigation.
540
+ *
541
+ * @param {string} pQuery - Case-insensitive search string.
542
+ */
543
+ search(pQuery)
544
+ {
545
+ if (!pQuery)
546
+ {
547
+ return;
548
+ }
549
+
550
+ if (this._outputBuffer.length === 0)
551
+ {
552
+ this.logWidget.setContent('{yellow-fg}{bold}No output to search.{/bold}{/yellow-fg}\n\nRun a command first, then search with [/].');
553
+ this.screen.render();
554
+ return;
555
+ }
556
+
557
+ this._searchQuery = pQuery;
558
+ this._searchMatches = [];
559
+ // Store individual result lines for navigation rebuilds
560
+ this._searchResultLines = [];
561
+
562
+ let tmpQueryLower = pQuery.toLowerCase();
563
+
564
+ // Build a regex to highlight matches (escape special regex chars in query)
565
+ let tmpEscaped = pQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
566
+ let tmpHighlightRegex = new RegExp(`(${tmpEscaped})`, 'gi');
567
+
568
+ for (let i = 0; i < this._outputBuffer.length; i++)
569
+ {
570
+ // Strip blessed markup tags for matching purposes
571
+ let tmpPlain = this._outputBuffer[i].replace(/\{[^}]*\}/g, '');
572
+ if (tmpPlain.toLowerCase().indexOf(tmpQueryLower) !== -1)
573
+ {
574
+ this._searchMatches.push(i);
575
+ let tmpLineNum = String(i + 1).padStart(5, ' ');
576
+ // Highlight the matching text in the display line
577
+ let tmpHighlighted = this._outputBuffer[i].replace(tmpHighlightRegex, '{yellow-fg}{bold}$1{/bold}{/yellow-fg}');
578
+ this._searchResultLines.push(`{gray-fg}${tmpLineNum}:{/gray-fg} ${tmpHighlighted}`);
579
+ }
580
+ }
581
+
582
+ this._searchMatchIndex = -1;
583
+ this._renderSearchResults();
584
+ }
585
+
586
+ /**
587
+ * Rebuild and display the search results view.
588
+ * When a match is selected (via navigation), that line gets a marker.
589
+ */
590
+ _renderSearchResults()
591
+ {
592
+ let tmpLines = [];
593
+
594
+ tmpLines.push(`{bold}Search: "${this._searchQuery}"{/bold} in ${this._outputBuffer.length} buffered lines`);
595
+ tmpLines.push('');
596
+
597
+ for (let i = 0; i < this._searchResultLines.length; i++)
598
+ {
599
+ if (i === this._searchMatchIndex)
600
+ {
601
+ // Current match: cyan background marker
602
+ tmpLines.push(`{cyan-fg}{bold}>>>{/bold}{/cyan-fg} ${this._searchResultLines[i]}`);
603
+ }
604
+ else
605
+ {
606
+ tmpLines.push(` ${this._searchResultLines[i]}`);
607
+ }
608
+ }
609
+
610
+ tmpLines.push('');
611
+ if (this._searchMatches.length === 0)
612
+ {
613
+ tmpLines.push(`{yellow-fg}{bold}No matches found{/bold}{/yellow-fg} for "${this._searchQuery}" in ${this._outputBuffer.length} lines`);
614
+ }
615
+ else
616
+ {
617
+ tmpLines.push(`{bold}${this._searchMatches.length} matches{/bold} ] next [ prev`);
618
+ }
619
+ tmpLines.push('[/] search again [Esc] back to full output');
620
+
621
+ this.logWidget.setContent(tmpLines.join('\n'));
622
+
623
+ // Scroll so the current match is visible
624
+ if (this._searchMatchIndex >= 0 && this._searchMatches.length > 0)
625
+ {
626
+ // +2 accounts for header lines before the result lines
627
+ let tmpTargetLine = this._searchMatchIndex + 2;
628
+ let tmpTotalLines = tmpLines.length;
629
+ let tmpScrollPerc = Math.max(0, Math.floor((tmpTargetLine / tmpTotalLines) * 100));
630
+ this.logWidget.setScrollPerc(tmpScrollPerc);
631
+
632
+ let tmpBufferLineIndex = this._searchMatches[this._searchMatchIndex];
633
+ this.statusCallback('search',
634
+ `Match ${this._searchMatchIndex + 1}/${this._searchMatches.length} line ${tmpBufferLineIndex + 1} ] next [ prev [/] search [Esc] done`);
635
+ }
636
+ else
637
+ {
638
+ this.logWidget.setScrollPerc(0);
639
+ }
640
+
641
+ this.screen.render();
642
+ }
643
+
644
+ /**
645
+ * Move to the next or previous search match within the results view.
646
+ *
647
+ * @param {number} pDirection - 1 for next, -1 for previous.
648
+ */
649
+ searchNavigate(pDirection)
650
+ {
651
+ if (!this._searchMatches || this._searchMatches.length === 0)
652
+ {
653
+ return;
654
+ }
655
+
656
+ this._searchMatchIndex += pDirection;
657
+
658
+ // Wrap around
659
+ if (this._searchMatchIndex >= this._searchMatches.length)
660
+ {
661
+ this._searchMatchIndex = 0;
662
+ }
663
+ else if (this._searchMatchIndex < 0)
664
+ {
665
+ this._searchMatchIndex = this._searchMatches.length - 1;
666
+ }
667
+
668
+ this._renderSearchResults();
669
+ }
670
+
671
+ /**
672
+ * Exit search mode and restore the full output buffer.
673
+ */
674
+ searchClear()
675
+ {
676
+ this._searchQuery = '';
677
+ this._searchMatches = null;
678
+ this._searchMatchIndex = -1;
679
+ this._searchResultLines = null;
680
+
681
+ if (this._outputBuffer.length > 0)
682
+ {
683
+ this.logWidget.setContent(this._outputBuffer.join('\n'));
684
+ this.logWidget.setScrollPerc(100);
685
+ this.screen.render();
686
+ }
687
+ }
688
+
689
+ /**
690
+ * @returns {boolean} Whether there is a buffer available to search.
691
+ */
692
+ hasBuffer()
693
+ {
694
+ return this._outputBuffer.length > 0;
695
+ }
696
+
697
+ /**
698
+ * @returns {boolean} Whether we are in search-results mode.
699
+ */
700
+ isSearchActive()
701
+ {
702
+ return this._searchMatches !== null && this._searchMatches !== undefined && this._searchMatches.length >= 0;
703
+ }
704
+ }
705
+
706
+ module.exports = ProcessRunner;