@lincoln504/pi-research 0.1.12 → 1.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.
- package/LICENSE +1 -1
- package/README.md +64 -115
- package/dist/cli.mjs +23211 -0
- package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
- package/dist/prompts/research-knowledge-search-extractor.md +41 -0
- package/dist/prompts/research-tool-usage.md +92 -0
- package/dist/prompts/researcher.md +89 -0
- package/dist/prompts/system-coordinator.md +75 -0
- package/dist/prompts/system-lead-evaluator.md +118 -0
- package/dist/thread-worker.mjs +2611 -0
- package/dist/webgpu-probe.mjs +80 -0
- package/docs/AGENT-SKILL.md +102 -0
- package/docs/ARCHITECTURE.md +306 -80
- package/docs/CONFIGURATION.md +249 -0
- package/docs/KNOWLEDGE-STORE.md +213 -0
- package/docs/PI-EXTENSION.md +91 -0
- package/docs/SDK.md +113 -0
- package/docs/THIRD-PARTY-NOTICES.md +64 -0
- package/package.json +102 -36
- package/scripts/build.cjs +140 -0
- package/scripts/cleanup.cjs +149 -0
- package/scripts/prepare.cjs +28 -0
- package/scripts/setup.cjs +134 -0
- package/skills/pi-research/README.md +90 -0
- package/skills/pi-research/SKILL.md +102 -0
- package/skills/pi-research/references/configuration.md +77 -0
- package/skills/pi-research/scripts/run.mjs +237 -0
- package/src/cleanup/research-cleanup.ts +140 -0
- package/src/cli.ts +1044 -0
- package/src/config.ts +1036 -147
- package/src/constants.ts +115 -84
- package/src/core/health-check-service.ts +265 -0
- package/src/core/interfaces/health-check-interfaces.ts +50 -0
- package/src/core/interfaces/knowledge-interfaces.ts +190 -0
- package/src/core/interfaces/observer-interfaces.ts +67 -0
- package/src/core/interfaces/orchestration-interfaces.ts +86 -0
- package/src/core/interfaces/planning-interfaces.ts +100 -0
- package/src/core/interfaces/process-interfaces.ts +37 -0
- package/src/core/interfaces/research-plan-types.ts +52 -0
- package/src/core/interfaces/scheduler-interfaces.ts +91 -0
- package/src/core/interfaces/service-names.ts +39 -0
- package/src/core/interfaces/state-manager-interfaces.ts +29 -0
- package/src/core/interfaces/steering-interfaces.ts +31 -0
- package/src/core/llm/agentic-repair.ts +169 -0
- package/src/core/llm/inject-date.ts +52 -0
- package/src/core/llm/llm-timeout.ts +64 -0
- package/src/core/llm/llm-utils.ts +86 -0
- package/src/core/llm/model-registry-factory.ts +287 -0
- package/src/core/llm/pi-ai-completion.ts +29 -0
- package/src/core/llm/prompts.ts +50 -0
- package/src/core/llm/research-model-resolver.ts +91 -0
- package/src/core/planning-constants.ts +13 -0
- package/src/core/planning-service.ts +649 -0
- package/src/core/planning-utils.ts +299 -0
- package/src/core/scheduler-factory.ts +48 -0
- package/src/core/scheduler-service.ts +123 -0
- package/src/core/service-initialization.ts +194 -0
- package/src/core/service-interfaces.ts +48 -0
- package/src/core/service-registry.ts +659 -0
- package/src/healthcheck/index.ts +271 -265
- package/src/healthcheck/registry.ts +130 -0
- package/src/index.ts +639 -50
- package/src/infrastructure/browser/browser-cleanup.ts +309 -0
- package/src/infrastructure/browser/browser-client.ts +199 -0
- package/src/infrastructure/browser/browser-error-utils.ts +160 -0
- package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
- package/src/infrastructure/browser/browser-server.ts +174 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
- package/src/infrastructure/browser/cleanup-utils.ts +155 -0
- package/src/infrastructure/browser/client-agent.ts +31 -0
- package/src/infrastructure/browser/config.ts +288 -0
- package/src/infrastructure/browser/ensure-browser.ts +205 -0
- package/src/infrastructure/browser/index.ts +37 -0
- package/src/infrastructure/browser/priority-task-queue.ts +226 -0
- package/src/infrastructure/browser/scheduler-factory.ts +354 -0
- package/src/infrastructure/browser/task-execution-service.ts +209 -0
- package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
- package/src/infrastructure/browser/thread-worker-types.ts +25 -0
- package/src/infrastructure/browser/thread-worker.mjs +2611 -0
- package/src/infrastructure/browser/thread-worker.ts +168 -0
- package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
- package/src/infrastructure/embedding/embedding-client.ts +205 -0
- package/src/infrastructure/embedding/embedding-factory.ts +324 -0
- package/src/infrastructure/embedding/embedding-server.ts +535 -0
- package/src/infrastructure/file-lock-service.ts +592 -0
- package/src/infrastructure/gpu-resource-service.ts +191 -0
- package/src/infrastructure/knowledge-store-service.ts +475 -0
- package/src/infrastructure/metrics-service.ts +129 -0
- package/src/infrastructure/process-lifecycle-service.ts +253 -0
- package/src/infrastructure/scheduler-factory-service.ts +58 -0
- package/src/infrastructure/service-initialization.ts +292 -0
- package/src/infrastructure/state/state-backup-manager.ts +290 -0
- package/src/infrastructure/state/state-browser-api.ts +62 -0
- package/src/infrastructure/state/state-browser-manager.ts +70 -0
- package/src/infrastructure/state/state-manager-service.ts +264 -0
- package/src/infrastructure/state/state-manager.ts +547 -0
- package/src/infrastructure/state/state-metrics.ts +92 -0
- package/src/infrastructure/state/state-migration.ts +103 -0
- package/src/infrastructure/state/state-path-configuration.ts +119 -0
- package/src/infrastructure/state/state-session-api.ts +94 -0
- package/src/infrastructure/state/state-session-manager.ts +127 -0
- package/src/infrastructure/state/state-validator.ts +93 -0
- package/src/infrastructure/types/state-types.ts +85 -0
- package/src/knowledge/chunker.ts +157 -0
- package/src/knowledge/embedder-init.ts +413 -0
- package/src/knowledge/embedder-utils.ts +296 -0
- package/src/knowledge/embedder.ts +592 -0
- package/src/knowledge/index.ts +126 -0
- package/src/knowledge/lancedb-loader.ts +35 -0
- package/src/knowledge/migration.ts +17 -0
- package/src/knowledge/model-config.ts +132 -0
- package/src/knowledge/onnx-env.ts +48 -0
- package/src/knowledge/store-operations.ts +291 -0
- package/src/knowledge/store-schema.ts +79 -0
- package/src/knowledge/store.ts +1251 -0
- package/src/knowledge/transformers-loader.ts +31 -0
- package/src/knowledge/webgpu-probe.mjs +80 -0
- package/src/knowledge/webgpu-viability.ts +255 -0
- package/src/knowledge/writer-queue.ts +252 -0
- package/src/logger.ts +316 -122
- package/src/observers/research-observer-impl.ts +459 -0
- package/src/orchestration/deep-research-orchestrator.ts +524 -773
- package/src/orchestration/deep-research-types.ts +47 -35
- package/src/orchestration/headless-observer.ts +114 -0
- package/src/orchestration/orchestration-types.ts +33 -0
- package/src/orchestration/quick-research-orchestrator.ts +463 -0
- package/src/orchestration/research-orchestration-service.ts +549 -0
- package/src/orchestration/research-session-service.ts +178 -0
- package/src/orchestration/research-synthesis-service.ts +348 -0
- package/src/orchestration/researcher-executor.ts +394 -0
- package/src/orchestration/researcher.ts +115 -42
- package/src/orchestration/service-initialization.ts +109 -0
- package/src/orchestration/session-context.ts +7 -3
- package/src/orchestration/session-state.ts +672 -0
- package/src/prompts/research-knowledge-relevance-triage.md +35 -0
- package/src/prompts/research-knowledge-search-extractor.md +41 -0
- package/src/prompts/research-tool-usage.md +63 -16
- package/src/prompts/researcher.md +59 -136
- package/src/prompts/system-coordinator.md +64 -13
- package/src/prompts/system-lead-evaluator.md +82 -189
- package/src/research-config.ts +872 -0
- package/src/sdk.ts +847 -0
- package/src/security/cisa-kev.ts +72 -9
- package/src/security/cvss.ts +92 -0
- package/src/security/ecosystem.ts +45 -0
- package/src/security/github-advisories.ts +168 -428
- package/src/security/github-advisory-types.ts +282 -0
- package/src/security/index.ts +63 -177
- package/src/security/nvd-client.ts +550 -0
- package/src/security/nvd-types.ts +179 -0
- package/src/security/osv-client.ts +246 -0
- package/src/security/osv-types.ts +285 -0
- package/src/security/types.ts +67 -53
- package/src/skill-install/skill-installer.ts +519 -0
- package/src/stackexchange/index.ts +81 -27
- package/src/stackexchange/output/compact.ts +7 -6
- package/src/stackexchange/output/table.ts +11 -7
- package/src/stackexchange/queries.ts +0 -10
- package/src/stackexchange/rest-client.ts +141 -66
- package/src/stackexchange/types.ts +0 -17
- package/src/tool.ts +15 -423
- package/src/tools/grep.ts +32 -310
- package/src/tools/health-tool-definition.ts +101 -0
- package/src/tools/index.ts +38 -9
- package/src/tools/research-knowledge-search.ts +974 -0
- package/src/tools/research-knowledge-types.ts +77 -0
- package/src/tools/research-tool-definition.ts +494 -0
- package/src/tools/scrape.ts +286 -388
- package/src/tools/search.ts +98 -153
- package/src/tools/security.ts +107 -80
- package/src/tools/stackexchange.ts +58 -35
- package/src/tools/youtube-transcript.ts +163 -0
- package/src/tui/knowledge-search-panel.ts +86 -0
- package/src/tui/pulse.ts +86 -0
- package/src/tui/research-health.ts +124 -0
- package/src/tui/research-panel-color-utils.ts +340 -0
- package/src/tui/research-panel-format.ts +54 -0
- package/src/tui/research-panel-state.ts +404 -0
- package/src/tui/research-panel-wave.ts +280 -0
- package/src/tui/research-panel.ts +339 -435
- package/src/tui/research-tui-manager.ts +233 -0
- package/src/tui/tui-controller.ts +137 -0
- package/src/types/extension-context.ts +18 -49
- package/src/types/index.ts +61 -0
- package/src/types/llm.ts +68 -29
- package/src/types/research-panel-types.ts +61 -0
- package/src/utils/circuit-breaker.ts +146 -0
- package/src/utils/citation-utils.ts +97 -0
- package/src/utils/disk-space-checker.ts +55 -0
- package/src/utils/error-tracker.ts +265 -0
- package/src/utils/host-config.ts +45 -0
- package/src/utils/input-validation.ts +8 -10
- package/src/utils/json-utils.ts +150 -16
- package/src/utils/llm-usage.ts +54 -0
- package/src/utils/log-rotation.ts +125 -0
- package/src/utils/log-utils.ts +159 -0
- package/src/utils/make-resource-loader.ts +3 -1
- package/src/utils/metrics-summary.ts +459 -0
- package/src/utils/metrics.ts +302 -0
- package/src/utils/research-export.ts +28 -12
- package/src/utils/safe-unref.ts +31 -0
- package/src/utils/shared-links.ts +195 -90
- package/src/utils/shutdown-manager.ts +65 -0
- package/src/utils/stdio-capture.ts +407 -0
- package/src/utils/text-utils.ts +264 -7
- package/src/utils/tool-usage-tracker.ts +83 -16
- package/src/utils/url-utils.ts +255 -0
- package/src/utils/user-agent.ts +28 -0
- package/src/web-research/browser-search.ts +179 -0
- package/src/web-research/retry-utils.ts +173 -77
- package/src/web-research/scraper-types.ts +72 -0
- package/src/web-research/scraper-utils.ts +434 -0
- package/src/web-research/search.ts +78 -218
- package/src/web-research/types.ts +9 -24
- package/src/web-research/utils.ts +2 -143
- package/src/web-research/web-scraper.ts +477 -0
- package/src/youtube/index.ts +154 -0
- package/src/youtube/potoken.ts +259 -0
- package/src/youtube/transcript-client.ts +390 -0
- package/src/youtube/video-id.ts +118 -0
- package/config/searxng/default-settings.yml +0 -189
- package/config/searxng/limiter.toml +0 -34
- package/config/tooling/eslint.config.js +0 -73
- package/config/tooling/vitest.config.integration.ts +0 -17
- package/config/tooling/vitest.config.ts +0 -40
- package/config/tooling/vitest.config.unit.ts +0 -12
- package/docs/assets/README-banner.jpg +0 -0
- package/scripts/setup.js +0 -174
- package/src/infrastructure/searxng-lifecycle.ts +0 -577
- package/src/infrastructure/searxng-manager.ts +0 -1183
- package/src/infrastructure/state-manager.ts +0 -918
- package/src/orchestration/deep-research-reducer.ts +0 -129
- package/src/orchestration/id-utils.ts +0 -63
- package/src/security/nvd.ts +0 -532
- package/src/security/osv.ts +0 -487
- package/src/stackexchange/cache.ts +0 -111
- package/src/stackexchange/output/index.ts +0 -7
- package/src/stackexchange/output/json.ts +0 -21
- package/src/tui/searxng-status.ts +0 -80
- package/src/utils/inject-date.ts +0 -29
- package/src/utils/research-error.ts +0 -205
- package/src/utils/searxng-config.ts +0 -94
- package/src/utils/session-state.ts +0 -295
- package/src/web-research/dependencies.ts +0 -20
- package/src/web-research/scrapers.ts +0 -935
- package/tsconfig.json +0 -40
|
@@ -1,339 +1,132 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Research TUI Panel
|
|
3
3
|
*
|
|
4
|
-
* Side-by-side
|
|
4
|
+
* Side-by-side researcher column layout with progress tracking.
|
|
5
|
+
* Adapted from the original design to work with the current browser-based
|
|
6
|
+
* architecture using DuckDuckGo Lite (Playwright).
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
|
-
import { type Component, visibleWidth, truncateToWidth } from '@
|
|
8
|
-
import type { Theme } from '
|
|
9
|
-
import
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
}
|
|
9
|
+
import { type Component, visibleWidth, truncateToWidth, type TUI } from '@earendil-works/pi-tui';
|
|
10
|
+
import type { Theme, ResearchPanelState } from '../types/research-panel-types.ts';
|
|
11
|
+
import { getSteeringMessages } from '../orchestration/session-state.ts';
|
|
12
|
+
import {
|
|
13
|
+
formatTokens,
|
|
14
|
+
renderProgressPct,
|
|
15
|
+
formatCost,
|
|
16
|
+
_formatTokens,
|
|
17
|
+
_renderProgressPct,
|
|
18
|
+
_formatCost,
|
|
19
|
+
} from './research-panel-format.ts';
|
|
20
|
+
import { generateWaveFill } from './research-panel-wave.ts';
|
|
85
21
|
|
|
86
22
|
/**
|
|
87
|
-
*
|
|
23
|
+
* Re-export types for convenience
|
|
88
24
|
*/
|
|
89
|
-
export
|
|
90
|
-
state.slices.set(id, { id, label, completed: false, queued, flash: null });
|
|
91
|
-
}
|
|
25
|
+
export type { Theme, SliceState, ResearchPanelState, ResearchProgress } from '../types/research-panel-types.ts';
|
|
92
26
|
|
|
93
27
|
/**
|
|
94
|
-
*
|
|
28
|
+
* Re-export state management functions
|
|
95
29
|
*/
|
|
96
|
-
export
|
|
97
|
-
|
|
98
|
-
|
|
30
|
+
export {
|
|
31
|
+
addSlice,
|
|
32
|
+
removeSlice,
|
|
33
|
+
activateSlice,
|
|
34
|
+
updateSliceTokens,
|
|
35
|
+
updateSliceStatus,
|
|
36
|
+
completeSlice,
|
|
37
|
+
reactivateSlice,
|
|
38
|
+
clearCompletedResearchers,
|
|
39
|
+
createInitialPanelState,
|
|
40
|
+
flashSlice,
|
|
41
|
+
clearAllFlashTimeouts,
|
|
42
|
+
} from './research-panel-state.ts';
|
|
99
43
|
|
|
100
44
|
/**
|
|
101
|
-
*
|
|
45
|
+
* Re-export formatting utilities (internal test exports)
|
|
102
46
|
*/
|
|
103
|
-
export
|
|
104
|
-
const slice = state.slices.get(id);
|
|
105
|
-
if (slice) slice.queued = false;
|
|
106
|
-
}
|
|
47
|
+
export { _formatTokens, _renderProgressPct, _formatCost } from './research-panel-format.ts';
|
|
107
48
|
|
|
108
49
|
/**
|
|
109
|
-
*
|
|
110
|
-
* Tokens are treated as current context size (latest value), while cost is accumulated.
|
|
50
|
+
* Re-export color utilities
|
|
111
51
|
*/
|
|
112
|
-
export
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
52
|
+
export {
|
|
53
|
+
derive256Gradient as _derive256Gradient,
|
|
54
|
+
deriveRgbGradient as _deriveRgbGradient,
|
|
55
|
+
rgbTo256 as _rgbTo256,
|
|
56
|
+
indexToRgb as _indexToRgb,
|
|
57
|
+
rgbToHsl as _rgbToHsl,
|
|
58
|
+
hslToRgb as _hslToRgb,
|
|
59
|
+
parseAnsiFgColor as _parseAnsiFgColor,
|
|
60
|
+
} from './research-panel-color-utils.ts';
|
|
119
61
|
|
|
120
62
|
/**
|
|
121
|
-
*
|
|
63
|
+
* Center `display` inside a column of `w` terminal cells, measuring by DISPLAY
|
|
64
|
+
* width (visibleWidth), not code units. String.padStart/padEnd count UTF-16 code
|
|
65
|
+
* units, so a CJK/emoji label (2 cells per glyph, and 2 code units per emoji)
|
|
66
|
+
* produced rows whose visible width disagreed with the border rows — the box
|
|
67
|
+
* edges no longer lined up. Exported for tests as _centerPadToWidth.
|
|
122
68
|
*/
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
}
|
|
69
|
+
function centerPadToWidth(display: string, w: number): string {
|
|
70
|
+
const pad = Math.max(0, w - visibleWidth(display));
|
|
71
|
+
const left = Math.floor(pad / 2);
|
|
72
|
+
return ' '.repeat(left) + display + ' '.repeat(pad - left);
|
|
146
73
|
}
|
|
147
74
|
|
|
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
|
-
}
|
|
75
|
+
export { centerPadToWidth as _centerPadToWidth };
|
|
158
76
|
|
|
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
77
|
function renderPanelBlock(
|
|
281
78
|
state: ResearchPanelState,
|
|
282
79
|
theme: Theme,
|
|
283
80
|
width: number,
|
|
284
|
-
showSearxng: boolean
|
|
285
81
|
): string[] {
|
|
286
|
-
const
|
|
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
|
-
|
|
82
|
+
const rightInner = Math.max(1, width - 2);
|
|
293
83
|
const numRows = 4;
|
|
294
84
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
85
|
+
const sliceIds = Array.from(state.slices.keys()).filter(id => {
|
|
86
|
+
const s = state.slices.get(id);
|
|
87
|
+
if (!s || s.queued) return false;
|
|
88
|
+
return true;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Stable sort: Planning/Coordinator first, Researchers numerically, Eval last
|
|
92
|
+
sliceIds.sort((a, b) => {
|
|
93
|
+
const sa = state.slices.get(a)!;
|
|
94
|
+
const sb = state.slices.get(b)!;
|
|
95
|
+
const la = sa.label.toLowerCase();
|
|
96
|
+
const lb = sb.label.toLowerCase();
|
|
97
|
+
|
|
98
|
+
// Priority 1: Coordinator — key off the stable slice id, NOT a label substring.
|
|
99
|
+
// In quick mode the slice id is the query-derived label, and a researcher's specialist
|
|
100
|
+
// label can contain "plan"/"coord" (e.g. "Urban Planning Specialist"); matching the
|
|
101
|
+
// label text would wrongly sort such a column into the coordinator's leading slot.
|
|
102
|
+
const isPlanA = a === 'coord';
|
|
103
|
+
const isPlanB = b === 'coord';
|
|
104
|
+
if (isPlanA && !isPlanB) return -1;
|
|
105
|
+
if (!isPlanA && isPlanB) return 1;
|
|
106
|
+
|
|
107
|
+
// Priority 3: Eval (Last) — key off the stable slice id, not the label text, so a
|
|
108
|
+
// quick-mode run whose query is literally "eval" isn't sorted/rendered as the eval box.
|
|
109
|
+
const isEvalA = a === 'eval';
|
|
110
|
+
const isEvalB = b === 'eval';
|
|
111
|
+
if (isEvalA && !isEvalB) return 1;
|
|
112
|
+
if (!isEvalA && isEvalB) return -1;
|
|
113
|
+
|
|
114
|
+
// Priority 2: Numerical researchers
|
|
115
|
+
const numA = parseInt(la, 10);
|
|
116
|
+
const numB = parseInt(lb, 10);
|
|
117
|
+
if (!isNaN(numA) && !isNaN(numB)) return numA - numB;
|
|
118
|
+
if (!isNaN(numA)) return -1;
|
|
119
|
+
if (!isNaN(numB)) return 1;
|
|
120
|
+
|
|
121
|
+
return la.localeCompare(lb);
|
|
122
|
+
});
|
|
323
123
|
|
|
324
|
-
const sliceIds = Array.from(state.slices.keys()).filter(id => !state.slices.get(id)!.queued);
|
|
325
124
|
const numSlices = sliceIds.length;
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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;
|
|
125
|
+
const visibleSliceIds = sliceIds;
|
|
126
|
+
const numVisible = numSlices;
|
|
127
|
+
const totalCols = numVisible;
|
|
128
|
+
const showIndicator = false;
|
|
129
|
+
const hiddenCount = 0;
|
|
337
130
|
|
|
338
131
|
// Calculate column widths for the right section
|
|
339
132
|
const dividers = totalCols - 1;
|
|
@@ -359,140 +152,181 @@ function renderPanelBlock(
|
|
|
359
152
|
const slice = sliceId ? state.slices.get(sliceId) : null;
|
|
360
153
|
const w = colW(i);
|
|
361
154
|
const isLast = i === totalCols - 1;
|
|
362
|
-
|
|
363
|
-
// Top Border with Label
|
|
155
|
+
|
|
364
156
|
const isIndicator = showIndicator && i === 0;
|
|
365
157
|
const labelStr = slice ? slice.label : `+${hiddenCount}`;
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
const
|
|
369
|
-
|
|
158
|
+
// Key the decorative eval box off the stable slice id, not label text (a quick-mode
|
|
159
|
+
// query literally "eval" would otherwise render as the evaluator's box).
|
|
160
|
+
const isEval = sliceId === 'eval';
|
|
161
|
+
|
|
162
|
+
// Determine right border character based on if next column is eval
|
|
163
|
+
const nextSliceId = i + 1 < totalCols ? (showIndicator && i + 1 === 0 ? null : (showIndicator ? visibleSliceIds[i] : visibleSliceIds[i + 1])) : null;
|
|
164
|
+
const nextIsEval = nextSliceId === 'eval';
|
|
165
|
+
|
|
166
|
+
// Top Border with Label — all sizing by DISPLAY width. labelStr can be the
|
|
167
|
+
// quick-mode query (CJK/emoji): measuring with .length (code units) made the
|
|
168
|
+
// border row wider/narrower than the body rows, breaking box alignment.
|
|
169
|
+
const labelPadding = 2; // Spaces around label
|
|
170
|
+
const labelWidth = visibleWidth(labelStr);
|
|
171
|
+
const totalLabelWidth = labelWidth + labelPadding;
|
|
370
172
|
|
|
371
173
|
let topPart;
|
|
372
174
|
if (isEval) {
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
} else if (
|
|
380
|
-
const sideWidth = w -
|
|
175
|
+
// Eval box: box-drawing dashes (─) at edges, en dashes (–) in middle
|
|
176
|
+
if (w >= 2) {
|
|
177
|
+
topPart = '─' + '-'.repeat(Math.max(0, w - 2)) + '─';
|
|
178
|
+
} else {
|
|
179
|
+
topPart = '─'.repeat(w);
|
|
180
|
+
}
|
|
181
|
+
} else if (w >= totalLabelWidth) {
|
|
182
|
+
const sideWidth = w - totalLabelWidth;
|
|
381
183
|
const leftPad = Math.floor(sideWidth / 2);
|
|
382
184
|
const rightPad = sideWidth - leftPad;
|
|
383
|
-
topPart = '─'.repeat(leftPad) + labelStr + '─'.repeat(rightPad);
|
|
185
|
+
topPart = '─'.repeat(leftPad) + ' ' + labelStr + ' ' + '─'.repeat(rightPad);
|
|
186
|
+
} else if (w >= 1 && labelStr.length > 0) {
|
|
187
|
+
// Tight space: show as much of the label as fits. truncateToWidth cuts at
|
|
188
|
+
// grapheme boundaries (never emits a lone surrogate, unlike the previous
|
|
189
|
+
// labelStr[0].slice(0, w)), and the '─' fill is sized by remaining CELLS
|
|
190
|
+
// (a single CJK first char occupies 2 cells, so padEnd-by-length overflowed).
|
|
191
|
+
const clipped = truncateToWidth(labelStr, w, '');
|
|
192
|
+
topPart = clipped + '─'.repeat(Math.max(0, w - visibleWidth(clipped)));
|
|
384
193
|
} else {
|
|
385
194
|
topPart = '─'.repeat(w);
|
|
386
195
|
}
|
|
387
|
-
|
|
196
|
+
|
|
197
|
+
const topRightCorner = isEval ? '╮' : (nextIsEval ? '╭' : (isLast ? '┐' : '┬'));
|
|
198
|
+
rightRawRows[0]!.push(topPart + topRightCorner);
|
|
388
199
|
rightColors[0]!.push('accent');
|
|
389
200
|
|
|
390
|
-
// Token Row (
|
|
201
|
+
// Token Row (row 1)
|
|
391
202
|
let tokenStr: string;
|
|
392
203
|
if (isEval) {
|
|
393
|
-
|
|
394
|
-
|
|
204
|
+
// Eval label display: ╷ eval ╷ or ╷ status ╷ with decorative borders
|
|
205
|
+
const innerWidth = Math.max(0, w - 2);
|
|
206
|
+
// Prioritize status if it exists, otherwise use 'eval'
|
|
207
|
+
const evalLabel = slice?.status || 'eval';
|
|
208
|
+
const labelDisplay = truncateToWidth(evalLabel, innerWidth);
|
|
209
|
+
const padding = innerWidth - visibleWidth(labelDisplay);
|
|
210
|
+
const leftPad = Math.floor(padding / 2);
|
|
211
|
+
const rightPad = padding - leftPad;
|
|
212
|
+
tokenStr = '╷' + ' '.repeat(leftPad) + labelDisplay + ' '.repeat(rightPad) + '╷';
|
|
395
213
|
} else if (isIndicator) {
|
|
396
|
-
tokenStr = '...'
|
|
214
|
+
tokenStr = centerPadToWidth('...', w);
|
|
397
215
|
} else {
|
|
398
|
-
|
|
216
|
+
// Identify the coordinator column by its stable slice id, not a label substring:
|
|
217
|
+
// "planning"/"complexity" in the (possibly query-derived, in quick mode) label would
|
|
218
|
+
// false-positive and blank out a real column's tokens/cost/status.
|
|
219
|
+
const isPlanning = sliceId === 'coord';
|
|
399
220
|
const tokens = slice?.tokens || 0;
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
221
|
+
let raw = '';
|
|
222
|
+
|
|
223
|
+
// Status ALWAYS shows (incl. the coordinator's round-1 search count "N results" and
|
|
224
|
+
// planning status) — it must NOT be gated by isPlanning. Only the token-count FALLBACK
|
|
225
|
+
// is suppressed for the coordinator column (it shows status/wave, not a token tally).
|
|
226
|
+
// Regression guard: keying isPlanning off sliceId==='coord' (0a9da110) had wrongly
|
|
227
|
+
// pulled the status branch under !isPlanning too, blanking the coordinator's live
|
|
228
|
+
// search count.
|
|
229
|
+
if (slice?.completed && slice?.failed) {
|
|
230
|
+
// Persistent terminal marker — NOT gated on !completed like the status branch,
|
|
231
|
+
// so a failed researcher stays visibly failed instead of muted-like-a-success.
|
|
232
|
+
raw = 'failed';
|
|
233
|
+
} else if (slice?.status && !slice.completed && !slice.queued) {
|
|
234
|
+
raw = slice.status;
|
|
235
|
+
} else if (!isPlanning && tokens > 0) {
|
|
236
|
+
raw = formatTokens(tokens);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Center by display width (padStart/padEnd count code units and misalign
|
|
240
|
+
// the box for wide/astral chars — e.g. a CJK status or query fragment).
|
|
241
|
+
tokenStr = centerPadToWidth(truncateToWidth(raw, w), w);
|
|
403
242
|
}
|
|
404
|
-
|
|
405
|
-
const
|
|
406
|
-
|
|
243
|
+
|
|
244
|
+
const rightWall12 = isEval ? '┊' : (nextIsEval ? '┊' : '│');
|
|
245
|
+
rightRawRows[1]!.push(tokenStr + rightWall12);
|
|
246
|
+
const f1 = slice?.flash === 'green' ? 'success' : slice?.flash === 'red' ? 'error' : null;
|
|
247
|
+
rightColors[1]!.push(f1 || (slice?.failed ? 'error' : slice?.completed ? 'muted' : 'text'));
|
|
407
248
|
|
|
408
249
|
// Cost Row (row 2)
|
|
409
250
|
let costStr: string;
|
|
410
251
|
if (isEval) {
|
|
411
|
-
|
|
252
|
+
// Eval cost row: ╵ (empty space) or ╵ cost ╵ with decorative borders
|
|
253
|
+
const innerWidth = Math.max(0, w - 2);
|
|
254
|
+
const cost = slice?.cost || 0;
|
|
255
|
+
const raw = cost === 0 ? '' : formatCost(cost);
|
|
256
|
+
const labelDisplay = truncateToWidth(raw, innerWidth);
|
|
257
|
+
const padding = innerWidth - visibleWidth(labelDisplay);
|
|
258
|
+
const leftPad = Math.floor(padding / 2);
|
|
259
|
+
const rightPad = padding - leftPad;
|
|
260
|
+
costStr = '╵' + ' '.repeat(leftPad) + labelDisplay + ' '.repeat(rightPad) + '╵';
|
|
412
261
|
} else if (isIndicator) {
|
|
413
|
-
|
|
414
|
-
costStr = display.padStart(Math.floor((w + display.length) / 2)).padEnd(w);
|
|
262
|
+
costStr = centerPadToWidth(truncateToWidth('...', w), w);
|
|
415
263
|
} else {
|
|
416
|
-
const isPlanning =
|
|
264
|
+
const isPlanning = sliceId === 'coord'; // coordinator column shows no per-column cost (see token row)
|
|
417
265
|
const cost = slice?.cost || 0;
|
|
418
266
|
const raw = (isPlanning || cost === 0) ? '' : formatCost(cost);
|
|
419
|
-
|
|
420
|
-
costStr = display.padStart(Math.floor((w + display.length) / 2)).padEnd(w);
|
|
267
|
+
costStr = centerPadToWidth(truncateToWidth(raw, w), w);
|
|
421
268
|
}
|
|
422
|
-
rightRawRows[2]!.push(costStr +
|
|
423
|
-
|
|
269
|
+
rightRawRows[2]!.push(costStr + rightWall12);
|
|
270
|
+
const f2 = slice?.flash === 'green' ? 'success' : slice?.flash === 'red' ? 'error' : null;
|
|
271
|
+
rightColors[2]!.push(f2 || (slice?.failed ? 'error' : slice?.completed ? 'muted' : 'text'));
|
|
424
272
|
|
|
425
273
|
// Bottom Border
|
|
426
|
-
|
|
274
|
+
let bottomContent;
|
|
275
|
+
if (isEval) {
|
|
276
|
+
// Eval box: box-drawing dashes (─) at edges, en dashes (–) in middle
|
|
277
|
+
if (w >= 2) {
|
|
278
|
+
bottomContent = '─' + '-'.repeat(Math.max(0, w - 2)) + '─';
|
|
279
|
+
} else {
|
|
280
|
+
bottomContent = '─'.repeat(w);
|
|
281
|
+
}
|
|
282
|
+
} else {
|
|
283
|
+
bottomContent = '─'.repeat(w);
|
|
284
|
+
}
|
|
285
|
+
const bottomRightCorner = isEval ? '╯' : (nextIsEval ? '╰' : (isLast ? '┘' : '┴'));
|
|
286
|
+
rightRawRows[3]!.push(bottomContent + bottomRightCorner);
|
|
427
287
|
rightColors[3]!.push('accent');
|
|
428
288
|
}
|
|
429
289
|
}
|
|
430
290
|
|
|
431
291
|
// Final Assembly
|
|
432
292
|
const blockResult: string[] = [];
|
|
433
|
-
const
|
|
434
|
-
|
|
293
|
+
const firstSliceId = visibleSliceIds[0];
|
|
294
|
+
const startsWithEval = firstSliceId === 'eval';
|
|
295
|
+
|
|
296
|
+
const leftChars = startsWithEval ? ['╭', '┊', '┊', '╰'] : ['┌', '│', '│', '└'];
|
|
297
|
+
|
|
435
298
|
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
|
-
|
|
299
|
+
let line = theme.fg('accent', leftChars[rowIdx]!);
|
|
300
|
+
|
|
466
301
|
const rightRowParts = rightRawRows[rowIdx]!;
|
|
467
302
|
const rightRowColors = rightColors[rowIdx]!;
|
|
468
|
-
|
|
303
|
+
|
|
469
304
|
for (let colIdx = 0; colIdx < rightRowParts.length; colIdx++) {
|
|
470
305
|
const part = rightRowParts[colIdx]!;
|
|
471
306
|
const content = part.slice(0, -1);
|
|
472
307
|
const wall = part.slice(-1);
|
|
473
|
-
|
|
474
|
-
if (rowIdx === 0) {
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
}
|
|
308
|
+
|
|
309
|
+
if (rowIdx === 0 || rowIdx === 3) {
|
|
310
|
+
// Top and bottom borders: all accent color
|
|
311
|
+
line += theme.fg('accent', content);
|
|
492
312
|
} else {
|
|
313
|
+
// Body rows (1-2)
|
|
493
314
|
const color = rightRowColors[colIdx]!;
|
|
494
|
-
|
|
315
|
+
const firstChar = content[0];
|
|
316
|
+
const lastChar = content[content.length - 1];
|
|
317
|
+
|
|
318
|
+
// Check if this is an eval decorative box (╷...╷ or ╵...╵)
|
|
319
|
+
if (content.length >= 2 && ((firstChar === '╷' && lastChar === '╷') || (firstChar === '╵' && lastChar === '╵'))) {
|
|
320
|
+
// Eval box: render decorative chars as accent, inner content as text
|
|
321
|
+
line += theme.fg('accent', firstChar as string);
|
|
322
|
+
line += theme.fg(color, content.slice(1, -1));
|
|
323
|
+
line += theme.fg('accent', lastChar as string);
|
|
324
|
+
} else {
|
|
325
|
+
line += theme.fg(color, content);
|
|
326
|
+
}
|
|
495
327
|
}
|
|
328
|
+
|
|
329
|
+
// Walls: accent color
|
|
496
330
|
line += theme.fg('accent', wall);
|
|
497
331
|
}
|
|
498
332
|
blockResult.push(line);
|
|
@@ -505,70 +339,140 @@ function renderPanelBlock(
|
|
|
505
339
|
* Renders ALL active research runs in that session into a single widget.
|
|
506
340
|
*/
|
|
507
341
|
export function createMasterResearchPanel(
|
|
508
|
-
piSessionId: string
|
|
509
|
-
|
|
510
|
-
|
|
342
|
+
piSessionId: string,
|
|
343
|
+
getActivePanelsFn?: (piSessionId: string) => ResearchPanelState[]
|
|
344
|
+
): (tui: TUI, theme: Theme) => Component {
|
|
345
|
+
const getPanels = getActivePanelsFn;
|
|
346
|
+
|
|
347
|
+
return (_tui: TUI, theme: Theme) => {
|
|
511
348
|
const component: Component = {
|
|
512
349
|
render(width: number): string[] {
|
|
513
|
-
|
|
350
|
+
// Safety guard for extremely narrow terminals
|
|
351
|
+
if (width < 4) return [];
|
|
352
|
+
|
|
353
|
+
const panels: ResearchPanelState[] = getPanels ? getPanels(piSessionId) : [];
|
|
354
|
+
|
|
355
|
+
// NOTE: render() runs on every frame (30 FPS during the wave animation).
|
|
356
|
+
// It must stay pure and allocation-light — NO logging here. A previous
|
|
357
|
+
// logger.debug() on this path issued a synchronous appendFileSync per
|
|
358
|
+
// frame, which (with DEBUG enabled) blocked the event loop and made the
|
|
359
|
+
// wave animation slow and jumpy. Diagnostics belong on state-change
|
|
360
|
+
// paths, not the render hot path.
|
|
514
361
|
if (panels.length === 0) return [];
|
|
515
362
|
|
|
516
363
|
const allLines: string[] = [];
|
|
517
364
|
for (let i = 0; i < panels.length; i++) {
|
|
518
365
|
const panel = panels[i]!;
|
|
519
|
-
|
|
520
|
-
// Header line for each block
|
|
366
|
+
|
|
367
|
+
// Header line for each block (fixed width, rounded corners)
|
|
521
368
|
const pctStr = renderProgressPct(panel.progress);
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
|
|
369
|
+
const label = panel.title ?? 'Research';
|
|
370
|
+
let headerText: string;
|
|
371
|
+
if (pctStr) {
|
|
372
|
+
headerText = ` ${label}: ${pctStr}`;
|
|
373
|
+
} else {
|
|
374
|
+
headerText = ` ${label}`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const maxWidth = Math.max(20, width - 4);
|
|
378
|
+
if (visibleWidth(headerText) > maxWidth) {
|
|
379
|
+
if (pctStr) {
|
|
380
|
+
const remainingLen = maxWidth - ` ${label}: ${pctStr} `.length;
|
|
381
|
+
if (remainingLen > 6) {
|
|
382
|
+
headerText = ` ${label}: ${pctStr} ..`;
|
|
383
|
+
} else {
|
|
384
|
+
headerText = ` ${label}: ${pctStr}`;
|
|
385
|
+
}
|
|
386
|
+
} else {
|
|
387
|
+
const remainingLen = maxWidth - ` ${label} `.length;
|
|
388
|
+
if (remainingLen > 6) {
|
|
389
|
+
headerText = ` ${label} ..`;
|
|
390
|
+
} else {
|
|
391
|
+
headerText = ` ${label}`;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Render header: ─╴ text ╶╮ (half-endpoint chars give breathing room around text)
|
|
397
|
+
// During active search the right side becomes a wave: ─╴ text ╶╼──────
|
|
398
|
+
const leftDecor = theme.fg('accent', '─╴');
|
|
399
|
+
const rightDecor = theme.fg('accent', ' ╶╮');
|
|
400
|
+
|
|
401
|
+
let headerLine = leftDecor + theme.fg('muted', headerText);
|
|
402
|
+
|
|
403
|
+
if (panel.isSearching) {
|
|
404
|
+
headerLine += ' '; // space between header text and wave
|
|
405
|
+
const currentWidth = visibleWidth(headerLine);
|
|
406
|
+
const targetWidth = Math.max(0, width - 1);
|
|
407
|
+
const available = Math.max(0, targetWidth - currentWidth);
|
|
408
|
+
|
|
409
|
+
if (available > 0 && panel.waveFrame !== undefined) {
|
|
410
|
+
const waveResult = generateWaveFill(theme, panel.waveFrame, panel.waveColors, available);
|
|
411
|
+
panel.waveColors = waveResult.updatedColors;
|
|
412
|
+
headerLine += waveResult.fill;
|
|
413
|
+
} else if (available > 0) {
|
|
414
|
+
// Fallback: fill with ─ before animation starts
|
|
415
|
+
headerLine += theme.fg('accent', '─'.repeat(available));
|
|
416
|
+
}
|
|
417
|
+
} else {
|
|
418
|
+
headerLine += rightDecor;
|
|
419
|
+
}
|
|
420
|
+
|
|
525
421
|
allLines.push(headerLine);
|
|
526
422
|
|
|
527
|
-
|
|
528
|
-
const isBottomMost = i === panels.length - 1;
|
|
529
|
-
const blockLines = renderPanelBlock(panel, theme, width, isBottomMost);
|
|
423
|
+
const blockLines = renderPanelBlock(panel, theme, width);
|
|
530
424
|
allLines.push(...blockLines);
|
|
531
425
|
}
|
|
532
426
|
|
|
533
|
-
//
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
427
|
+
// Render steering messages once at the bottom (not duplicated under each panel)
|
|
428
|
+
const allSteering = getSteeringMessages(piSessionId);
|
|
429
|
+
const isSteeringAcceptable = panels.some(p => p.steeringAcceptable === true);
|
|
430
|
+
|
|
431
|
+
if (allSteering.length > 0) {
|
|
432
|
+
// Messages exist (queued or active) — show them regardless of phase.
|
|
433
|
+
// Queued messages persist through eval/coordinator phases so the user
|
|
434
|
+
// can see what's waiting to be consumed at the next round boundary.
|
|
435
|
+
const maxSteeringLines = 3;
|
|
436
|
+
const visibleSteering = allSteering.slice(-maxSteeringLines);
|
|
437
|
+
if (allSteering.length > maxSteeringLines) {
|
|
438
|
+
const hiddenCount = allSteering.length - maxSteeringLines;
|
|
439
|
+
allLines.push(theme.fg('dim', ` ... and ${hiddenCount} older steering message(s) hidden`));
|
|
538
440
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
441
|
+
for (const msg of visibleSteering) {
|
|
442
|
+
const prefix = msg.status === 'queued' ? ' QUEUED RESEARCH STEERING: ' : ' RESEARCH STEERING: ';
|
|
443
|
+
const display = truncateToWidth(`${prefix}${msg.text}`, width);
|
|
444
|
+
// Use 'warning' style for queued (amber/yellow), 'muted' for active
|
|
445
|
+
const color = msg.status === 'queued' ? 'warning' : 'muted';
|
|
446
|
+
allLines.push(theme.fg(color as any, display));
|
|
447
|
+
}
|
|
448
|
+
} else if (isSteeringAcceptable) {
|
|
449
|
+
// No messages yet but steering is acceptable — show the "available" hint
|
|
450
|
+
allLines.push(theme.fg('muted', ' available for research steering'));
|
|
451
|
+
}
|
|
452
|
+
// When steering is not acceptable and there are no messages: show nothing.
|
|
549
453
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
render(width: number): string[] {
|
|
559
|
-
return renderPanelBlock(state, theme, width, !state.hideSearxng).map(line => {
|
|
454
|
+
if (panels.length > 0) {
|
|
455
|
+
// Hint line: show pop steering hint whenever there are queued (poppable) messages
|
|
456
|
+
const hasQueued = allSteering.some(m => m.status === 'queued');
|
|
457
|
+
const hint = hasQueued ? ' esc cancel research | alt+p pop steering' : ' esc to cancel research';
|
|
458
|
+
allLines.push(theme.fg('muted', hint));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return allLines.map(line => {
|
|
560
462
|
const w = visibleWidth(line);
|
|
561
463
|
if (w > width) {
|
|
562
|
-
return truncateToWidth(line, Math.max(
|
|
464
|
+
return truncateToWidth(line, Math.max(1, width));
|
|
563
465
|
}
|
|
564
466
|
return line;
|
|
565
467
|
});
|
|
566
468
|
},
|
|
567
469
|
invalidate(): void {
|
|
568
|
-
//
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
470
|
+
// Propagate up the component tree so the framework's render pipeline
|
|
471
|
+
// is triggered.
|
|
472
|
+
const comp = component as Component & { parent?: { invalidate: () => void } };
|
|
473
|
+
if (comp.parent && typeof comp.parent.invalidate === 'function') {
|
|
474
|
+
comp.parent.invalidate();
|
|
475
|
+
}
|
|
572
476
|
}
|
|
573
477
|
};
|
|
574
478
|
return component;
|