@lincoln504/pi-research 0.0.0

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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -0
  3. package/config/searxng/default-settings.yml +189 -0
  4. package/config/searxng/limiter.toml +34 -0
  5. package/config/tooling/eslint.config.js +73 -0
  6. package/config/tooling/vitest.config.integration.ts +17 -0
  7. package/config/tooling/vitest.config.ts +40 -0
  8. package/config/tooling/vitest.config.unit.ts +12 -0
  9. package/docs/ARCHITECTURE.md +109 -0
  10. package/docs/assets/README-banner.jpg +0 -0
  11. package/package.json +92 -0
  12. package/scripts/setup.js +174 -0
  13. package/src/config.ts +206 -0
  14. package/src/constants.ts +148 -0
  15. package/src/healthcheck/index.ts +309 -0
  16. package/src/index.ts +71 -0
  17. package/src/infrastructure/searxng-lifecycle.ts +577 -0
  18. package/src/infrastructure/searxng-manager.ts +1173 -0
  19. package/src/infrastructure/state-manager.ts +918 -0
  20. package/src/logger.ts +246 -0
  21. package/src/orchestration/deep-research-orchestrator.ts +848 -0
  22. package/src/orchestration/deep-research-reducer.ts +129 -0
  23. package/src/orchestration/deep-research-types.ts +54 -0
  24. package/src/orchestration/id-utils.ts +63 -0
  25. package/src/orchestration/researcher.ts +104 -0
  26. package/src/orchestration/session-context.ts +43 -0
  27. package/src/prompts/research-tool-usage.md +45 -0
  28. package/src/prompts/researcher.md +166 -0
  29. package/src/prompts/system-coordinator.md +24 -0
  30. package/src/prompts/system-lead-evaluator.md +225 -0
  31. package/src/security/cisa-kev.ts +242 -0
  32. package/src/security/github-advisories.ts +607 -0
  33. package/src/security/index.ts +406 -0
  34. package/src/security/nvd.ts +532 -0
  35. package/src/security/osv.ts +487 -0
  36. package/src/security/types.ts +138 -0
  37. package/src/stackexchange/cache.ts +111 -0
  38. package/src/stackexchange/index.ts +352 -0
  39. package/src/stackexchange/output/compact.ts +64 -0
  40. package/src/stackexchange/output/index.ts +7 -0
  41. package/src/stackexchange/output/json.ts +21 -0
  42. package/src/stackexchange/output/table.ts +123 -0
  43. package/src/stackexchange/queries.ts +168 -0
  44. package/src/stackexchange/rest-client.ts +127 -0
  45. package/src/stackexchange/types.ts +129 -0
  46. package/src/tool.ts +430 -0
  47. package/src/tools/grep.ts +335 -0
  48. package/src/tools/index.ts +77 -0
  49. package/src/tools/scrape.ts +451 -0
  50. package/src/tools/search.ts +188 -0
  51. package/src/tools/security.ts +292 -0
  52. package/src/tools/stackexchange.ts +101 -0
  53. package/src/tui/research-panel.ts +576 -0
  54. package/src/tui/searxng-status.ts +80 -0
  55. package/src/types/extension-context.ts +60 -0
  56. package/src/types/llm.ts +111 -0
  57. package/src/utils/inject-date.ts +29 -0
  58. package/src/utils/input-validation.ts +103 -0
  59. package/src/utils/json-utils.ts +262 -0
  60. package/src/utils/make-resource-loader.ts +43 -0
  61. package/src/utils/research-error.ts +205 -0
  62. package/src/utils/research-export.ts +199 -0
  63. package/src/utils/searxng-config.ts +94 -0
  64. package/src/utils/session-state.ts +295 -0
  65. package/src/utils/shared-links.ts +123 -0
  66. package/src/utils/shutdown-manager.ts +54 -0
  67. package/src/utils/text-utils.ts +83 -0
  68. package/src/utils/tool-usage-tracker.ts +135 -0
  69. package/src/web-research/dependencies.ts +20 -0
  70. package/src/web-research/retry-utils.ts +232 -0
  71. package/src/web-research/scrapers.ts +935 -0
  72. package/src/web-research/search.ts +235 -0
  73. package/src/web-research/types.ts +47 -0
  74. package/src/web-research/utils.ts +158 -0
  75. package/tsconfig.json +40 -0
@@ -0,0 +1,576 @@
1
+ /**
2
+ * Research TUI Panel
3
+ *
4
+ * Side-by-side layout for SearXNG status and researcher progress.
5
+ */
6
+
7
+ import { type Component, visibleWidth, truncateToWidth } from '@mariozechner/pi-tui';
8
+ import type { Theme } from '@mariozechner/pi-coding-agent';
9
+ import type { SearxngStatus } from '../infrastructure/searxng-lifecycle.ts';
10
+ import { getPiActivePanels } from '../utils/session-state.ts';
11
+ import { FLASH_DURATION_MS } from '../constants.ts';
12
+
13
+ export interface SliceState {
14
+ id: string;
15
+ label: string; // Displayed label: "1", "2", "3", etc.
16
+ completed: boolean;
17
+ queued: boolean;
18
+ flash: 'green' | 'red' | null;
19
+ tokens?: number; // Tokens for this specific agent
20
+ cost?: number; // Cost for this specific agent
21
+ }
22
+
23
+ export interface ResearchProgress {
24
+ /** Total tool calls expected across all researchers in the initial plan. */
25
+ expected: number;
26
+ /** Actual tool calls completed so far. */
27
+ made: number;
28
+ /** True when research has expanded beyond the initial planned researchers. */
29
+ extended: boolean;
30
+ }
31
+
32
+ export interface ResearchPanelState {
33
+ sessionId: string;
34
+ query: string;
35
+ searxngStatus: SearxngStatus;
36
+ totalTokens: number;
37
+ slices: Map<string, SliceState>;
38
+ modelName: string;
39
+ hideSearxng?: boolean;
40
+ /** Optional progress bar data. Set after planning completes. */
41
+ progress?: ResearchProgress;
42
+ /** Temporary status message displayed in the header (e.g. 'planning...') */
43
+ statusMessage?: string;
44
+ }
45
+
46
+ // Store timeouts per session ID to prevent cross-session conflicts
47
+ const sessionTimeouts = new Map<string, Set<NodeJS.Timeout>>();
48
+ // Store active flash timeouts per researcher ID within each session
49
+ const sessionResearcherTimeouts = new Map<string, Map<string, NodeJS.Timeout>>();
50
+
51
+ /**
52
+ * Default duration for researcher cell flash indicators
53
+ */
54
+ export const DEFAULT_FLASH_DURATION_MS = FLASH_DURATION_MS;
55
+
56
+ /**
57
+ * Clear all active flash timeouts for a specific session
58
+ */
59
+ export function clearAllFlashTimeouts(sessionId?: string): void {
60
+ if (sessionId) {
61
+ // Clear timeouts for specific session
62
+ const timeouts = sessionTimeouts.get(sessionId);
63
+ if (timeouts) {
64
+ for (const timeout of timeouts) {
65
+ clearTimeout(timeout);
66
+ }
67
+ timeouts.clear();
68
+ }
69
+ sessionTimeouts.delete(sessionId);
70
+
71
+ // Clear researcher mapping for this session
72
+ sessionResearcherTimeouts.delete(sessionId);
73
+ } else {
74
+ // Clear all timeouts across all sessions
75
+ for (const timeouts of sessionTimeouts.values()) {
76
+ for (const timeout of timeouts) {
77
+ clearTimeout(timeout);
78
+ }
79
+ timeouts.clear();
80
+ }
81
+ sessionTimeouts.clear();
82
+ sessionResearcherTimeouts.clear();
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Add a new researcher column
88
+ */
89
+ export function addSlice(state: ResearchPanelState, id: string, label: string, queued: boolean = false): void {
90
+ state.slices.set(id, { id, label, completed: false, queued, flash: null });
91
+ }
92
+
93
+ /**
94
+ * Remove a researcher column
95
+ */
96
+ export function removeSlice(state: ResearchPanelState, id: string): void {
97
+ state.slices.delete(id);
98
+ }
99
+
100
+ /**
101
+ * Mark researcher as active (start from queued state)
102
+ */
103
+ export function activateSlice(state: ResearchPanelState, id: string): void {
104
+ const slice = state.slices.get(id);
105
+ if (slice) slice.queued = false;
106
+ }
107
+
108
+ /**
109
+ * Update researcher tokens and cost.
110
+ * Tokens are treated as current context size (latest value), while cost is accumulated.
111
+ */
112
+ export function updateSliceTokens(state: ResearchPanelState, id: string, tokens: number, cost: number): void {
113
+ const slice = state.slices.get(id);
114
+ if (slice) {
115
+ slice.tokens = tokens;
116
+ slice.cost = (slice.cost || 0) + cost;
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Mark researcher as complete
122
+ */
123
+ export function completeSlice(state: ResearchPanelState, id: string): void {
124
+ const slice = state.slices.get(id);
125
+ if (slice) {
126
+ slice.completed = true;
127
+ slice.queued = false;
128
+
129
+ // Explicitly clear any active flash when completing
130
+ const researcherTimeouts = sessionResearcherTimeouts.get(state.sessionId);
131
+ if (researcherTimeouts) {
132
+ const timeout = researcherTimeouts.get(id);
133
+ if (timeout) {
134
+ clearTimeout(timeout);
135
+ researcherTimeouts.delete(id);
136
+
137
+ // Also remove from session set
138
+ const timeouts = sessionTimeouts.get(state.sessionId);
139
+ if (timeouts) {
140
+ timeouts.delete(timeout);
141
+ }
142
+ }
143
+ }
144
+ slice.flash = null;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Re-mark a researcher as active (used when promoting a completed researcher to lead evaluator)
150
+ */
151
+ export function reactivateSlice(state: ResearchPanelState, id: string): void {
152
+ const slice = state.slices.get(id);
153
+ if (slice) {
154
+ slice.completed = false;
155
+ slice.flash = null;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Flash a researcher green or red
161
+ */
162
+ export function flashSlice(
163
+ state: ResearchPanelState,
164
+ researcherId: string,
165
+ color: 'green' | 'red',
166
+ durationMs: number = DEFAULT_FLASH_DURATION_MS,
167
+ onUpdate?: () => void
168
+ ): void {
169
+ const slice = state.slices.get(researcherId);
170
+ if (!slice || slice.completed || slice.queued) return;
171
+
172
+ // Ensure session map exists
173
+ if (!sessionResearcherTimeouts.has(state.sessionId)) {
174
+ sessionResearcherTimeouts.set(state.sessionId, new Map());
175
+ }
176
+ const researcherTimeouts = sessionResearcherTimeouts.get(state.sessionId)!;
177
+
178
+ // Clear any existing timeout for THIS researcher in THIS session to prevent overlapping resets
179
+ const existingTimeout = researcherTimeouts.get(researcherId);
180
+ if (existingTimeout) {
181
+ clearTimeout(existingTimeout);
182
+ researcherTimeouts.delete(researcherId);
183
+
184
+ // Also remove from session set
185
+ const timeouts = sessionTimeouts.get(state.sessionId);
186
+ if (timeouts) {
187
+ timeouts.delete(existingTimeout);
188
+ }
189
+ }
190
+
191
+ slice.flash = color;
192
+ onUpdate?.();
193
+
194
+ const timeout = setTimeout(() => {
195
+ slice.flash = null;
196
+ onUpdate?.();
197
+
198
+ // Cleanup
199
+ researcherTimeouts.delete(researcherId);
200
+ const timeouts = sessionTimeouts.get(state.sessionId);
201
+ if (timeouts) {
202
+ timeouts.delete(timeout);
203
+ }
204
+ }, durationMs);
205
+
206
+ // Track the new timeout
207
+ researcherTimeouts.set(researcherId, timeout);
208
+ if (!sessionTimeouts.has(state.sessionId)) {
209
+ sessionTimeouts.set(state.sessionId, new Set());
210
+ }
211
+ sessionTimeouts.get(state.sessionId)!.add(timeout);
212
+ }
213
+
214
+ /**
215
+ * Create initial panel state
216
+ */
217
+ export function createInitialPanelState(sessionId: string, query: string, searxngStatus: SearxngStatus, modelName: string): ResearchPanelState {
218
+ return {
219
+ sessionId,
220
+ query,
221
+ searxngStatus,
222
+ totalTokens: 0,
223
+ slices: new Map(),
224
+ modelName,
225
+ };
226
+ }
227
+
228
+ function formatTokens(tokens: number): string {
229
+ if (tokens < 1000) return tokens.toString();
230
+ if (tokens < 10000) return `${(tokens / 1000).toFixed(1)}k`;
231
+ if (tokens < 1_000_000) return `${Math.round(tokens / 1000)}k`;
232
+ return `${(tokens / 1_000_000).toFixed(1)}M`;
233
+ }
234
+
235
+ /**
236
+ * Render a percentage string for the header (no bar, no brackets).
237
+ * Returns empty string when progress data is absent or not yet initialised.
238
+ * The percentage is global: it reflects all researchers' tool calls combined,
239
+ * with the final report step weighted at 2× a regular tool call.
240
+ */
241
+ function renderProgressPct(progress: ResearchProgress | undefined): string {
242
+ if (!progress || progress.expected <= 0) return '';
243
+ if (progress.extended) return 'exploring';
244
+ const pct = Math.round(Math.min(1, progress.made / progress.expected) * 100);
245
+ return `${pct}%`;
246
+ }
247
+
248
+ function formatCost(cost: number): string {
249
+ if (cost === 0) return '$0.00';
250
+ if (cost < 0.0001) return '<$0.01';
251
+ if (cost < 1) {
252
+ // Show up to 4 decimal places for sub-dollar amounts, but strip trailing zeros
253
+ const s = cost.toFixed(4);
254
+ return `$${parseFloat(s)}`;
255
+ }
256
+ if (cost < 100) return `$${cost.toFixed(2)}`;
257
+ return `$${Math.round(cost)}`;
258
+ }
259
+
260
+ function getStatusText(state: string): string {
261
+ switch (state) {
262
+ case 'starting_up': return 'SearXNG';
263
+ case 'active': return 'SearXNG';
264
+ case 'inactive': return 'Offline';
265
+ case 'error': return 'Error';
266
+ default: return '?';
267
+ }
268
+ }
269
+
270
+ function extractPort(url: string): string {
271
+ if (!url) return '';
272
+ const m = url.match(/:(\d+)(?:\/|$)/);
273
+ return m ? `:${m[1]}` : '';
274
+ }
275
+
276
+ /**
277
+ * Internal rendering logic for a single research panel block.
278
+ * Returns 3 or 4 lines of TUI content depending on whether costs are present.
279
+ */
280
+ function renderPanelBlock(
281
+ state: ResearchPanelState,
282
+ theme: Theme,
283
+ width: number,
284
+ showSearxng: boolean
285
+ ): string[] {
286
+ const LEFT_INNER = 7;
287
+ const LEFT_BOX_W = LEFT_INNER + 2;
288
+ const GAP = 1;
289
+ const totalLeftOffset = LEFT_BOX_W + GAP;
290
+ const availableForRight = Math.max(1, width - totalLeftOffset);
291
+ const rightInner = Math.max(1, availableForRight - 2);
292
+
293
+ const numRows = 4;
294
+
295
+ // Prep left box raw parts (no colors yet)
296
+ interface RawLeftBox {
297
+ top: string;
298
+ rows: string[];
299
+ bottom: string;
300
+ statusColor: 'success' | 'error' | 'muted';
301
+ }
302
+
303
+ let leftRaw: RawLeftBox | null = null;
304
+
305
+ if (showSearxng && width >= totalLeftOffset + 10) {
306
+ const status = state.searxngStatus;
307
+ const statusText = getStatusText(status.state).trim();
308
+ const portStr = extractPort(status.url).trim();
309
+
310
+ // 4 rows: row 1 is status, row 2 is port
311
+ const rows = [
312
+ '│' + statusText.padEnd(LEFT_INNER),
313
+ '│' + portStr.padEnd(LEFT_INNER)
314
+ ];
315
+
316
+ leftRaw = {
317
+ top: '┌' + '─'.repeat(LEFT_INNER),
318
+ rows,
319
+ bottom: '└' + '─'.repeat(LEFT_INNER),
320
+ statusColor: status.state === 'error' ? 'error' : (status.state === 'active' || status.state === 'starting_up') ? 'success' : 'muted',
321
+ };
322
+ }
323
+
324
+ const sliceIds = Array.from(state.slices.keys()).filter(id => !state.slices.get(id)!.queued);
325
+ const numSlices = sliceIds.length;
326
+ const MAX_VISIBLE_SLICES = 6;
327
+ let visibleSliceIds = sliceIds;
328
+ let showIndicator = false;
329
+ let hiddenCount = 0;
330
+ if (numSlices > MAX_VISIBLE_SLICES) {
331
+ visibleSliceIds = sliceIds.slice(numSlices - MAX_VISIBLE_SLICES);
332
+ hiddenCount = numSlices - MAX_VISIBLE_SLICES;
333
+ showIndicator = true;
334
+ }
335
+ const numVisible = showIndicator ? MAX_VISIBLE_SLICES : numSlices;
336
+ const totalCols = showIndicator ? numVisible + 1 : numVisible;
337
+
338
+ // Calculate column widths for the right section
339
+ const dividers = totalCols - 1;
340
+ const contentTotal = Math.max(0, rightInner - dividers);
341
+ const colBase = Math.floor(contentTotal / totalCols);
342
+ const extra = contentTotal % totalCols;
343
+ const colW = (i: number) => Math.max(0, colBase + (i < extra ? 1 : 0));
344
+
345
+ // Build right box raw parts
346
+ const rightRawRows: string[][] = Array.from({ length: numRows }, () => []);
347
+ const rightColors: Array<Array<'success' | 'error' | 'muted' | 'text' | 'accent'>> = Array.from({ length: numRows }, () => []);
348
+
349
+ if (numVisible === 0) {
350
+ const w = Math.max(1, rightInner);
351
+ rightRawRows[0]!.push('─'.repeat(w) + '┐');
352
+ rightRawRows[1]!.push(' '.repeat(w) + '│');
353
+ rightRawRows[2]!.push(' '.repeat(w) + '│');
354
+ rightRawRows[3]!.push('─'.repeat(w) + '┘');
355
+ for(let i=0; i<numRows; i++) rightColors[i]!.push('accent');
356
+ } else {
357
+ for (let i = 0; i < totalCols; i++) {
358
+ const sliceId = showIndicator && i === 0 ? null : (showIndicator ? visibleSliceIds[i - 1] : visibleSliceIds[i]);
359
+ const slice = sliceId ? state.slices.get(sliceId) : null;
360
+ const w = colW(i);
361
+ const isLast = i === totalCols - 1;
362
+
363
+ // Top Border with Label
364
+ const isIndicator = showIndicator && i === 0;
365
+ const labelStr = slice ? slice.label : `+${hiddenCount}`;
366
+ const isEval = labelStr === 'Eval';
367
+ const cornerLabel = `┐ ${labelStr} ┌`;
368
+ const canShowCornerLabel = w >= cornerLabel.length;
369
+ const canShowBasicLabel = w >= labelStr.length + 2;
370
+
371
+ let topPart;
372
+ if (isEval) {
373
+ topPart = '─'.repeat(w);
374
+ } else if (canShowCornerLabel) {
375
+ const sideWidth = w - cornerLabel.length;
376
+ const leftPad = Math.floor(sideWidth / 2);
377
+ const rightPad = sideWidth - leftPad;
378
+ topPart = '─'.repeat(leftPad) + cornerLabel + '─'.repeat(rightPad);
379
+ } else if (canShowBasicLabel) {
380
+ const sideWidth = w - labelStr.length;
381
+ const leftPad = Math.floor(sideWidth / 2);
382
+ const rightPad = sideWidth - leftPad;
383
+ topPart = '─'.repeat(leftPad) + labelStr + '─'.repeat(rightPad);
384
+ } else {
385
+ topPart = '─'.repeat(w);
386
+ }
387
+ rightRawRows[0]!.push(topPart + (isLast ? '┐' : '┬'));
388
+ rightColors[0]!.push('accent');
389
+
390
+ // Token Row (always row 1)
391
+ let tokenStr: string;
392
+ if (isEval) {
393
+ const evalDisplay = 'Eval'.length > w ? 'Eval'.slice(0, w) : 'Eval';
394
+ tokenStr = evalDisplay.padStart(Math.floor((w + evalDisplay.length) / 2)).padEnd(w);
395
+ } else if (isIndicator) {
396
+ tokenStr = '...'.padStart(Math.floor((w + 3) / 2)).padEnd(w);
397
+ } else {
398
+ const isPlanning = labelStr.includes('planning') || labelStr.includes('complexity');
399
+ const tokens = slice?.tokens || 0;
400
+ const raw = (isPlanning || tokens === 0) ? '' : formatTokens(tokens);
401
+ const display = raw.length > w ? raw.slice(0, w) : raw;
402
+ tokenStr = display.padStart(Math.floor((w + display.length) / 2)).padEnd(w);
403
+ }
404
+ rightRawRows[1]!.push(tokenStr + '│');
405
+ const flashColor = slice?.flash === 'green' ? 'success' : slice?.flash === 'red' ? 'error' : null;
406
+ rightColors[1]!.push(flashColor || (slice?.completed ? 'muted' : 'text'));
407
+
408
+ // Cost Row (row 2)
409
+ let costStr: string;
410
+ if (isEval) {
411
+ costStr = ' '.repeat(w);
412
+ } else if (isIndicator) {
413
+ const display = '...'.length > w ? '...'.slice(0, w) : '...';
414
+ costStr = display.padStart(Math.floor((w + display.length) / 2)).padEnd(w);
415
+ } else {
416
+ const isPlanning = labelStr.includes('planning') || labelStr.includes('complexity');
417
+ const cost = slice?.cost || 0;
418
+ const raw = (isPlanning || cost === 0) ? '' : formatCost(cost);
419
+ const display = raw.length > w ? raw.slice(0, w) : raw;
420
+ costStr = display.padStart(Math.floor((w + display.length) / 2)).padEnd(w);
421
+ }
422
+ rightRawRows[2]!.push(costStr + '│');
423
+ rightColors[2]!.push(flashColor || (slice?.completed ? 'muted' : 'text'));
424
+
425
+ // Bottom Border
426
+ rightRawRows[3]!.push('─'.repeat(w) + (isLast ? '┘' : '┴'));
427
+ rightColors[3]!.push('accent');
428
+ }
429
+ }
430
+
431
+ // Final Assembly
432
+ const blockResult: string[] = [];
433
+ const jointChars = ['┬', '│', '│', '┴'];
434
+
435
+ for (let rowIdx = 0; rowIdx < 4; rowIdx++) {
436
+ let line = '';
437
+
438
+ if (leftRaw) {
439
+ const leftBorderColor = 'accent';
440
+ let leftPartContent: string;
441
+ let leftPartColor: 'success' | 'error' | 'muted' | 'text' | 'accent';
442
+
443
+ if (rowIdx === 0) {
444
+ leftPartContent = leftRaw.top;
445
+ leftPartColor = leftBorderColor;
446
+ } else if (rowIdx === 3) {
447
+ leftPartContent = leftRaw.bottom;
448
+ leftPartColor = leftBorderColor;
449
+ } else {
450
+ // Middle rows (1 and 2)
451
+ leftPartContent = leftRaw.rows[rowIdx - 1] || '│' + ' '.repeat(LEFT_INNER);
452
+ // Only the status row (row 1) gets the status color
453
+ leftPartColor = rowIdx === 1 ? leftRaw.statusColor : leftBorderColor;
454
+ }
455
+
456
+ line += theme.fg(leftBorderColor, leftPartContent.slice(0, 1));
457
+ line += theme.fg(leftPartColor, leftPartContent.slice(1));
458
+ line += theme.fg('accent', jointChars[rowIdx]!);
459
+ } else {
460
+ // No SearXNG box
461
+ const noBorderChars = ['┌', '│', '│', '└'];
462
+ line += ' '.repeat(totalLeftOffset - 1);
463
+ line += theme.fg('accent', noBorderChars[rowIdx]!);
464
+ }
465
+
466
+ const rightRowParts = rightRawRows[rowIdx]!;
467
+ const rightRowColors = rightColors[rowIdx]!;
468
+
469
+ for (let colIdx = 0; colIdx < rightRowParts.length; colIdx++) {
470
+ const part = rightRowParts[colIdx]!;
471
+ const content = part.slice(0, -1);
472
+ const wall = part.slice(-1);
473
+
474
+ if (rowIdx === 0) {
475
+ const cornerStart = content.indexOf('┐');
476
+ if (cornerStart !== -1) {
477
+ const cornerEnd = content.lastIndexOf('┌') + 1;
478
+ const before = content.slice(0, cornerStart);
479
+ const cornerOpen = content.slice(cornerStart, cornerStart + 1);
480
+ const labelText = content.slice(cornerStart + 1, cornerEnd - 1);
481
+ const cornerClose = content.slice(cornerEnd - 1, cornerEnd);
482
+ const after = content.slice(cornerEnd);
483
+
484
+ line += theme.fg('accent', before);
485
+ line += theme.fg('accent', cornerOpen);
486
+ line += theme.fg('muted', labelText);
487
+ line += theme.fg('accent', cornerClose);
488
+ line += theme.fg('accent', after);
489
+ } else {
490
+ line += theme.fg('accent', content);
491
+ }
492
+ } else {
493
+ const color = rightRowColors[colIdx]!;
494
+ line += theme.fg(color, content);
495
+ }
496
+ line += theme.fg('accent', wall);
497
+ }
498
+ blockResult.push(line);
499
+ }
500
+ return blockResult;
501
+ }
502
+
503
+ /**
504
+ * Create master research panel component for a Pi session.
505
+ * Renders ALL active research runs in that session into a single widget.
506
+ */
507
+ export function createMasterResearchPanel(
508
+ piSessionId: string
509
+ ): (tui: unknown, theme: Theme) => Component {
510
+ return (_tui: unknown, theme: Theme) => {
511
+ const component: Component = {
512
+ render(width: number): string[] {
513
+ const panels = getPiActivePanels(piSessionId);
514
+ if (panels.length === 0) return [];
515
+
516
+ const allLines: string[] = [];
517
+ for (let i = 0; i < panels.length; i++) {
518
+ const panel = panels[i]!;
519
+
520
+ // Header line for each block
521
+ const pctStr = renderProgressPct(panel.progress);
522
+ const status = panel.statusMessage ? ` [${panel.statusMessage}] ` : '';
523
+ const headerText = pctStr ? ` Research: ${pctStr}${status} ` : (status ? ` Research: ${status} ` : ` Research `);
524
+ const headerLine = theme.fg('accent', '─'.repeat(2)) + theme.fg('muted', headerText) + theme.fg('accent', '─'.repeat(Math.max(0, width - 2 - headerText.length)));
525
+ allLines.push(headerLine);
526
+
527
+ // Show SearXNG box only for the bottom-most panel in the Master Widget stack
528
+ const isBottomMost = i === panels.length - 1;
529
+ const blockLines = renderPanelBlock(panel, theme, width, isBottomMost);
530
+ allLines.push(...blockLines);
531
+ }
532
+
533
+ // Safety: truncate any line that exceeds terminal width
534
+ return allLines.map(line => {
535
+ const w = visibleWidth(line);
536
+ if (w > width) {
537
+ return truncateToWidth(line, Math.max(3, width - 1));
538
+ }
539
+ return line;
540
+ });
541
+ },
542
+ invalidate(): void {
543
+ // Master widget is refreshed explicitly via masterUpdateRegistry
544
+ }
545
+ };
546
+ return component;
547
+ };
548
+ }
549
+
550
+ /**
551
+ * Legacy: Create individual research panel component
552
+ */
553
+ export function createResearchPanel(
554
+ state: ResearchPanelState
555
+ ): (tui: unknown, theme: Theme) => Component & { dispose?(): void } {
556
+ return (_tui: unknown, theme: Theme) => {
557
+ const component: any = {
558
+ render(width: number): string[] {
559
+ return renderPanelBlock(state, theme, width, !state.hideSearxng).map(line => {
560
+ const w = visibleWidth(line);
561
+ if (w > width) {
562
+ return truncateToWidth(line, Math.max(3, width - 1));
563
+ }
564
+ return line;
565
+ });
566
+ },
567
+ invalidate(): void {
568
+ // No-op
569
+ },
570
+ dispose(): void {
571
+ clearAllFlashTimeouts(state.sessionId);
572
+ }
573
+ };
574
+ return component;
575
+ };
576
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * SearXNG Status Component (Left Box)
3
+ *
4
+ * Super minimal display: [starting] n:0 or [active] n:1 or [inactive] n:0 or [error] n:0
5
+ * This is shown in the left square of the TUI.
6
+ */
7
+
8
+ import type { Component } from '@mariozechner/pi-tui';
9
+ import type { Theme } from '@mariozechner/pi-coding-agent';
10
+ import type { SearxngStatus } from '../infrastructure/searxng-lifecycle.ts';
11
+
12
+ let capturedTui: { requestRender?(): void } | null = null;
13
+
14
+ export function getCapturedTui(): { requestRender?(): void } | null {
15
+ return capturedTui;
16
+ }
17
+
18
+ export function createSearxngStatusComponent(initialStatus: SearxngStatus): (tui: unknown, theme: Theme) => Component & { dispose?(): void } {
19
+ let status = initialStatus;
20
+
21
+ const updateStatus = (newStatus: SearxngStatus) => {
22
+ status = newStatus;
23
+ capturedTui?.requestRender?.();
24
+ };
25
+
26
+ return (tui: unknown, theme: Theme) => {
27
+ // Capture TUI reference for external re-render triggers
28
+ capturedTui = tui as { requestRender?(): void };
29
+
30
+ const component: Component = {
31
+ render(width: number): string[] {
32
+ const boxWidth = Math.min(width, 14);
33
+
34
+ // Format status: [starting] n:0 or [active] n:1 or [inactive] n:0 or [error] n:0
35
+ let statusText: string;
36
+ let statusColor: (text: string) => string;
37
+
38
+ switch (status.state) {
39
+ case 'starting_up':
40
+ statusText = '[starting]';
41
+ statusColor = (text: string) => theme.fg('muted', text);
42
+ break;
43
+ case 'active':
44
+ statusText = '[active]';
45
+ statusColor = (text: string) => theme.fg('success', text);
46
+ break;
47
+ case 'inactive':
48
+ statusText = '[inactive]';
49
+ statusColor = (text: string) => theme.fg('muted', text);
50
+ break;
51
+ case 'error':
52
+ statusText = '[error]';
53
+ statusColor = (text: string) => theme.fg('error', text);
54
+ break;
55
+ default:
56
+ statusText = '[?]';
57
+ statusColor = (text: string) => theme.fg('muted', text);
58
+ }
59
+
60
+ const content = `${statusColor(statusText)}`;
61
+
62
+ return [
63
+ `┌${'─'.repeat(boxWidth - 2)}┐`,
64
+ `│${content.padEnd(boxWidth - 2)}│`,
65
+ `│${' '.repeat(boxWidth - 2)}│`,
66
+ `└${'─'.repeat(boxWidth - 2)}┘`,
67
+ ];
68
+ },
69
+
70
+ invalidate(): void {
71
+ // No cached state to invalidate
72
+ },
73
+ };
74
+
75
+ // Expose update method
76
+ (component as any).updateStatus = updateStatus;
77
+
78
+ return component;
79
+ };
80
+ }