@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.
Files changed (248) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +64 -115
  3. package/dist/cli.mjs +23211 -0
  4. package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
  5. package/dist/prompts/research-knowledge-search-extractor.md +41 -0
  6. package/dist/prompts/research-tool-usage.md +92 -0
  7. package/dist/prompts/researcher.md +89 -0
  8. package/dist/prompts/system-coordinator.md +75 -0
  9. package/dist/prompts/system-lead-evaluator.md +118 -0
  10. package/dist/thread-worker.mjs +2611 -0
  11. package/dist/webgpu-probe.mjs +80 -0
  12. package/docs/AGENT-SKILL.md +102 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +213 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +113 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +64 -0
  19. package/package.json +102 -36
  20. package/scripts/build.cjs +140 -0
  21. package/scripts/cleanup.cjs +149 -0
  22. package/scripts/prepare.cjs +28 -0
  23. package/scripts/setup.cjs +134 -0
  24. package/skills/pi-research/README.md +90 -0
  25. package/skills/pi-research/SKILL.md +102 -0
  26. package/skills/pi-research/references/configuration.md +77 -0
  27. package/skills/pi-research/scripts/run.mjs +237 -0
  28. package/src/cleanup/research-cleanup.ts +140 -0
  29. package/src/cli.ts +1044 -0
  30. package/src/config.ts +1036 -147
  31. package/src/constants.ts +115 -84
  32. package/src/core/health-check-service.ts +265 -0
  33. package/src/core/interfaces/health-check-interfaces.ts +50 -0
  34. package/src/core/interfaces/knowledge-interfaces.ts +190 -0
  35. package/src/core/interfaces/observer-interfaces.ts +67 -0
  36. package/src/core/interfaces/orchestration-interfaces.ts +86 -0
  37. package/src/core/interfaces/planning-interfaces.ts +100 -0
  38. package/src/core/interfaces/process-interfaces.ts +37 -0
  39. package/src/core/interfaces/research-plan-types.ts +52 -0
  40. package/src/core/interfaces/scheduler-interfaces.ts +91 -0
  41. package/src/core/interfaces/service-names.ts +39 -0
  42. package/src/core/interfaces/state-manager-interfaces.ts +29 -0
  43. package/src/core/interfaces/steering-interfaces.ts +31 -0
  44. package/src/core/llm/agentic-repair.ts +169 -0
  45. package/src/core/llm/inject-date.ts +52 -0
  46. package/src/core/llm/llm-timeout.ts +64 -0
  47. package/src/core/llm/llm-utils.ts +86 -0
  48. package/src/core/llm/model-registry-factory.ts +287 -0
  49. package/src/core/llm/pi-ai-completion.ts +29 -0
  50. package/src/core/llm/prompts.ts +50 -0
  51. package/src/core/llm/research-model-resolver.ts +91 -0
  52. package/src/core/planning-constants.ts +13 -0
  53. package/src/core/planning-service.ts +649 -0
  54. package/src/core/planning-utils.ts +299 -0
  55. package/src/core/scheduler-factory.ts +48 -0
  56. package/src/core/scheduler-service.ts +123 -0
  57. package/src/core/service-initialization.ts +194 -0
  58. package/src/core/service-interfaces.ts +48 -0
  59. package/src/core/service-registry.ts +659 -0
  60. package/src/healthcheck/index.ts +271 -265
  61. package/src/healthcheck/registry.ts +130 -0
  62. package/src/index.ts +639 -50
  63. package/src/infrastructure/browser/browser-cleanup.ts +309 -0
  64. package/src/infrastructure/browser/browser-client.ts +199 -0
  65. package/src/infrastructure/browser/browser-error-utils.ts +160 -0
  66. package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
  67. package/src/infrastructure/browser/browser-server.ts +174 -0
  68. package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
  69. package/src/infrastructure/browser/cleanup-utils.ts +155 -0
  70. package/src/infrastructure/browser/client-agent.ts +31 -0
  71. package/src/infrastructure/browser/config.ts +288 -0
  72. package/src/infrastructure/browser/ensure-browser.ts +205 -0
  73. package/src/infrastructure/browser/index.ts +37 -0
  74. package/src/infrastructure/browser/priority-task-queue.ts +226 -0
  75. package/src/infrastructure/browser/scheduler-factory.ts +354 -0
  76. package/src/infrastructure/browser/task-execution-service.ts +209 -0
  77. package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
  78. package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
  79. package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
  80. package/src/infrastructure/browser/thread-worker-types.ts +25 -0
  81. package/src/infrastructure/browser/thread-worker.mjs +2611 -0
  82. package/src/infrastructure/browser/thread-worker.ts +168 -0
  83. package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
  84. package/src/infrastructure/embedding/embedding-client.ts +205 -0
  85. package/src/infrastructure/embedding/embedding-factory.ts +324 -0
  86. package/src/infrastructure/embedding/embedding-server.ts +535 -0
  87. package/src/infrastructure/file-lock-service.ts +592 -0
  88. package/src/infrastructure/gpu-resource-service.ts +191 -0
  89. package/src/infrastructure/knowledge-store-service.ts +475 -0
  90. package/src/infrastructure/metrics-service.ts +129 -0
  91. package/src/infrastructure/process-lifecycle-service.ts +253 -0
  92. package/src/infrastructure/scheduler-factory-service.ts +58 -0
  93. package/src/infrastructure/service-initialization.ts +292 -0
  94. package/src/infrastructure/state/state-backup-manager.ts +290 -0
  95. package/src/infrastructure/state/state-browser-api.ts +62 -0
  96. package/src/infrastructure/state/state-browser-manager.ts +70 -0
  97. package/src/infrastructure/state/state-manager-service.ts +264 -0
  98. package/src/infrastructure/state/state-manager.ts +547 -0
  99. package/src/infrastructure/state/state-metrics.ts +92 -0
  100. package/src/infrastructure/state/state-migration.ts +103 -0
  101. package/src/infrastructure/state/state-path-configuration.ts +119 -0
  102. package/src/infrastructure/state/state-session-api.ts +94 -0
  103. package/src/infrastructure/state/state-session-manager.ts +127 -0
  104. package/src/infrastructure/state/state-validator.ts +93 -0
  105. package/src/infrastructure/types/state-types.ts +85 -0
  106. package/src/knowledge/chunker.ts +157 -0
  107. package/src/knowledge/embedder-init.ts +413 -0
  108. package/src/knowledge/embedder-utils.ts +296 -0
  109. package/src/knowledge/embedder.ts +592 -0
  110. package/src/knowledge/index.ts +126 -0
  111. package/src/knowledge/lancedb-loader.ts +35 -0
  112. package/src/knowledge/migration.ts +17 -0
  113. package/src/knowledge/model-config.ts +132 -0
  114. package/src/knowledge/onnx-env.ts +48 -0
  115. package/src/knowledge/store-operations.ts +291 -0
  116. package/src/knowledge/store-schema.ts +79 -0
  117. package/src/knowledge/store.ts +1251 -0
  118. package/src/knowledge/transformers-loader.ts +31 -0
  119. package/src/knowledge/webgpu-probe.mjs +80 -0
  120. package/src/knowledge/webgpu-viability.ts +255 -0
  121. package/src/knowledge/writer-queue.ts +252 -0
  122. package/src/logger.ts +316 -122
  123. package/src/observers/research-observer-impl.ts +459 -0
  124. package/src/orchestration/deep-research-orchestrator.ts +524 -773
  125. package/src/orchestration/deep-research-types.ts +47 -35
  126. package/src/orchestration/headless-observer.ts +114 -0
  127. package/src/orchestration/orchestration-types.ts +33 -0
  128. package/src/orchestration/quick-research-orchestrator.ts +463 -0
  129. package/src/orchestration/research-orchestration-service.ts +549 -0
  130. package/src/orchestration/research-session-service.ts +178 -0
  131. package/src/orchestration/research-synthesis-service.ts +348 -0
  132. package/src/orchestration/researcher-executor.ts +394 -0
  133. package/src/orchestration/researcher.ts +115 -42
  134. package/src/orchestration/service-initialization.ts +109 -0
  135. package/src/orchestration/session-context.ts +7 -3
  136. package/src/orchestration/session-state.ts +672 -0
  137. package/src/prompts/research-knowledge-relevance-triage.md +35 -0
  138. package/src/prompts/research-knowledge-search-extractor.md +41 -0
  139. package/src/prompts/research-tool-usage.md +63 -16
  140. package/src/prompts/researcher.md +59 -136
  141. package/src/prompts/system-coordinator.md +64 -13
  142. package/src/prompts/system-lead-evaluator.md +82 -189
  143. package/src/research-config.ts +872 -0
  144. package/src/sdk.ts +847 -0
  145. package/src/security/cisa-kev.ts +72 -9
  146. package/src/security/cvss.ts +92 -0
  147. package/src/security/ecosystem.ts +45 -0
  148. package/src/security/github-advisories.ts +168 -428
  149. package/src/security/github-advisory-types.ts +282 -0
  150. package/src/security/index.ts +63 -177
  151. package/src/security/nvd-client.ts +550 -0
  152. package/src/security/nvd-types.ts +179 -0
  153. package/src/security/osv-client.ts +246 -0
  154. package/src/security/osv-types.ts +285 -0
  155. package/src/security/types.ts +67 -53
  156. package/src/skill-install/skill-installer.ts +519 -0
  157. package/src/stackexchange/index.ts +81 -27
  158. package/src/stackexchange/output/compact.ts +7 -6
  159. package/src/stackexchange/output/table.ts +11 -7
  160. package/src/stackexchange/queries.ts +0 -10
  161. package/src/stackexchange/rest-client.ts +141 -66
  162. package/src/stackexchange/types.ts +0 -17
  163. package/src/tool.ts +15 -423
  164. package/src/tools/grep.ts +32 -310
  165. package/src/tools/health-tool-definition.ts +101 -0
  166. package/src/tools/index.ts +38 -9
  167. package/src/tools/research-knowledge-search.ts +974 -0
  168. package/src/tools/research-knowledge-types.ts +77 -0
  169. package/src/tools/research-tool-definition.ts +494 -0
  170. package/src/tools/scrape.ts +286 -388
  171. package/src/tools/search.ts +98 -153
  172. package/src/tools/security.ts +107 -80
  173. package/src/tools/stackexchange.ts +58 -35
  174. package/src/tools/youtube-transcript.ts +163 -0
  175. package/src/tui/knowledge-search-panel.ts +86 -0
  176. package/src/tui/pulse.ts +86 -0
  177. package/src/tui/research-health.ts +124 -0
  178. package/src/tui/research-panel-color-utils.ts +340 -0
  179. package/src/tui/research-panel-format.ts +54 -0
  180. package/src/tui/research-panel-state.ts +404 -0
  181. package/src/tui/research-panel-wave.ts +280 -0
  182. package/src/tui/research-panel.ts +339 -435
  183. package/src/tui/research-tui-manager.ts +233 -0
  184. package/src/tui/tui-controller.ts +137 -0
  185. package/src/types/extension-context.ts +18 -49
  186. package/src/types/index.ts +61 -0
  187. package/src/types/llm.ts +68 -29
  188. package/src/types/research-panel-types.ts +61 -0
  189. package/src/utils/circuit-breaker.ts +146 -0
  190. package/src/utils/citation-utils.ts +97 -0
  191. package/src/utils/disk-space-checker.ts +55 -0
  192. package/src/utils/error-tracker.ts +265 -0
  193. package/src/utils/host-config.ts +45 -0
  194. package/src/utils/input-validation.ts +8 -10
  195. package/src/utils/json-utils.ts +150 -16
  196. package/src/utils/llm-usage.ts +54 -0
  197. package/src/utils/log-rotation.ts +125 -0
  198. package/src/utils/log-utils.ts +159 -0
  199. package/src/utils/make-resource-loader.ts +3 -1
  200. package/src/utils/metrics-summary.ts +459 -0
  201. package/src/utils/metrics.ts +302 -0
  202. package/src/utils/research-export.ts +28 -12
  203. package/src/utils/safe-unref.ts +31 -0
  204. package/src/utils/shared-links.ts +195 -90
  205. package/src/utils/shutdown-manager.ts +65 -0
  206. package/src/utils/stdio-capture.ts +407 -0
  207. package/src/utils/text-utils.ts +264 -7
  208. package/src/utils/tool-usage-tracker.ts +83 -16
  209. package/src/utils/url-utils.ts +255 -0
  210. package/src/utils/user-agent.ts +28 -0
  211. package/src/web-research/browser-search.ts +179 -0
  212. package/src/web-research/retry-utils.ts +173 -77
  213. package/src/web-research/scraper-types.ts +72 -0
  214. package/src/web-research/scraper-utils.ts +434 -0
  215. package/src/web-research/search.ts +78 -218
  216. package/src/web-research/types.ts +9 -24
  217. package/src/web-research/utils.ts +2 -143
  218. package/src/web-research/web-scraper.ts +477 -0
  219. package/src/youtube/index.ts +154 -0
  220. package/src/youtube/potoken.ts +259 -0
  221. package/src/youtube/transcript-client.ts +390 -0
  222. package/src/youtube/video-id.ts +118 -0
  223. package/config/searxng/default-settings.yml +0 -189
  224. package/config/searxng/limiter.toml +0 -34
  225. package/config/tooling/eslint.config.js +0 -73
  226. package/config/tooling/vitest.config.integration.ts +0 -17
  227. package/config/tooling/vitest.config.ts +0 -40
  228. package/config/tooling/vitest.config.unit.ts +0 -12
  229. package/docs/assets/README-banner.jpg +0 -0
  230. package/scripts/setup.js +0 -174
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -577
  232. package/src/infrastructure/searxng-manager.ts +0 -1183
  233. package/src/infrastructure/state-manager.ts +0 -918
  234. package/src/orchestration/deep-research-reducer.ts +0 -129
  235. package/src/orchestration/id-utils.ts +0 -63
  236. package/src/security/nvd.ts +0 -532
  237. package/src/security/osv.ts +0 -487
  238. package/src/stackexchange/cache.ts +0 -111
  239. package/src/stackexchange/output/index.ts +0 -7
  240. package/src/stackexchange/output/json.ts +0 -21
  241. package/src/tui/searxng-status.ts +0 -80
  242. package/src/utils/inject-date.ts +0 -29
  243. package/src/utils/research-error.ts +0 -205
  244. package/src/utils/searxng-config.ts +0 -94
  245. package/src/utils/session-state.ts +0 -295
  246. package/src/web-research/dependencies.ts +0 -20
  247. package/src/web-research/scrapers.ts +0 -935
  248. package/tsconfig.json +0 -40
@@ -1,339 +1,132 @@
1
1
  /**
2
2
  * Research TUI Panel
3
3
  *
4
- * Side-by-side layout for SearXNG status and researcher progress.
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 '@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
- }
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
- * Add a new researcher column
23
+ * Re-export types for convenience
88
24
  */
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
- }
25
+ export type { Theme, SliceState, ResearchPanelState, ResearchProgress } from '../types/research-panel-types.ts';
92
26
 
93
27
  /**
94
- * Remove a researcher column
28
+ * Re-export state management functions
95
29
  */
96
- export function removeSlice(state: ResearchPanelState, id: string): void {
97
- state.slices.delete(id);
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
- * Mark researcher as active (start from queued state)
45
+ * Re-export formatting utilities (internal test exports)
102
46
  */
103
- export function activateSlice(state: ResearchPanelState, id: string): void {
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
- * Update researcher tokens and cost.
110
- * Tokens are treated as current context size (latest value), while cost is accumulated.
50
+ * Re-export color utilities
111
51
  */
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
- }
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
- * Mark researcher as complete
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
- 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
- }
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 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
-
82
+ const rightInner = Math.max(1, width - 2);
293
83
  const numRows = 4;
294
84
 
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
- }
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 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;
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
- const isEval = labelStr === 'Eval';
367
- const cornerLabel = `┐ ${labelStr} ┌`;
368
- const canShowCornerLabel = w >= cornerLabel.length;
369
- const canShowBasicLabel = w >= labelStr.length + 2;
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
- 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;
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
- rightRawRows[0]!.push(topPart + (isLast ? '┐' : '┬'));
196
+
197
+ const topRightCorner = isEval ? '╮' : (nextIsEval ? '╭' : (isLast ? '┐' : '┬'));
198
+ rightRawRows[0]!.push(topPart + topRightCorner);
388
199
  rightColors[0]!.push('accent');
389
200
 
390
- // Token Row (always row 1)
201
+ // Token Row (row 1)
391
202
  let tokenStr: string;
392
203
  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);
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 = '...'.padStart(Math.floor((w + 3) / 2)).padEnd(w);
214
+ tokenStr = centerPadToWidth('...', w);
397
215
  } else {
398
- const isPlanning = labelStr.includes('planning') || labelStr.includes('complexity');
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
- 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);
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
- 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'));
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
- costStr = ' '.repeat(w);
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
- const display = '...'.length > w ? '...'.slice(0, w) : '...';
414
- costStr = display.padStart(Math.floor((w + display.length) / 2)).padEnd(w);
262
+ costStr = centerPadToWidth(truncateToWidth('...', w), w);
415
263
  } else {
416
- const isPlanning = labelStr.includes('planning') || labelStr.includes('complexity');
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
- const display = raw.length > w ? raw.slice(0, w) : raw;
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
- rightColors[2]!.push(flashColor || (slice?.completed ? 'muted' : 'text'));
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
- rightRawRows[3]!.push('─'.repeat(w) + (isLast ? '┘' : '┴'));
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 jointChars = ['┬', '│', '│', '┴'];
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
- 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
- }
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
- line += theme.fg(color, content);
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
- ): (tui: unknown, theme: Theme) => Component {
510
- return (_tui: unknown, theme: Theme) => {
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
- const panels = getPiActivePanels(piSessionId);
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 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)));
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
- // 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);
423
+ const blockLines = renderPanelBlock(panel, theme, width);
530
424
  allLines.push(...blockLines);
531
425
  }
532
426
 
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));
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
- return line;
540
- });
541
- },
542
- invalidate(): void {
543
- // Master widget is refreshed explicitly via masterUpdateRegistry
544
- }
545
- };
546
- return component;
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
- * 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 => {
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(3, width - 1));
464
+ return truncateToWidth(line, Math.max(1, width));
563
465
  }
564
466
  return line;
565
467
  });
566
468
  },
567
469
  invalidate(): void {
568
- // No-op
569
- },
570
- dispose(): void {
571
- clearAllFlashTimeouts(state.sessionId);
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;