@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
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Research Panel State Management
|
|
3
|
+
*
|
|
4
|
+
* Functions for managing research panel state, including
|
|
5
|
+
* the per-slice flash queue for tool result feedback.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
ResearchPanelState,
|
|
10
|
+
} from '../types/research-panel-types.ts';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
FLASH_GREEN_DURATION_MS,
|
|
14
|
+
FLASH_RED_DURATION_MS,
|
|
15
|
+
FLASH_STATUS_DURATION_MS,
|
|
16
|
+
FLASH_QUEUE_GAP_MS,
|
|
17
|
+
} from '../constants.ts';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Flash queue internals
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
interface FlashEntry {
|
|
24
|
+
color: 'green' | 'red';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface SliceFlashState {
|
|
28
|
+
/** Non-null when a color flash is active OR we are in the inter-flash gap. */
|
|
29
|
+
activeTimeout: NodeJS.Timeout | null;
|
|
30
|
+
/** The color currently being displayed. Null during inter-flash gap. */
|
|
31
|
+
activeColor: 'green' | 'red' | null;
|
|
32
|
+
colorQueue: FlashEntry[];
|
|
33
|
+
|
|
34
|
+
/** Non-null when a status 'pop' is active OR we are in the inter-flash gap. */
|
|
35
|
+
statusTimeout: NodeJS.Timeout | null;
|
|
36
|
+
/** The status text currently being displayed. Null during inter-flash gap. */
|
|
37
|
+
activeStatus: string | null;
|
|
38
|
+
statusQueue: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Maximum queued items per slice.
|
|
43
|
+
*/
|
|
44
|
+
const MAX_QUEUE_SIZE = 8;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Per-research-run flash state: Map<researchId, Map<sliceId, SliceFlashState>>.
|
|
48
|
+
*
|
|
49
|
+
* Keyed by researchId, NOT the pi sessionId: a single pi session can host several
|
|
50
|
+
* concurrent research runs, and they all reuse the same slice ids ('coord', 'eval',
|
|
51
|
+
* '1', '2', …). Keying by sessionId let one run's flashes render against — or, on
|
|
52
|
+
* teardown, cancel — another run's, sometimes leaving a column stuck colored.
|
|
53
|
+
* researchId is unique per run, so this isolates them.
|
|
54
|
+
*/
|
|
55
|
+
const flashStateByRun = new Map<string, Map<string, SliceFlashState>>();
|
|
56
|
+
|
|
57
|
+
function getOrCreateSliceFlash(researchId: string, sliceId: string): SliceFlashState {
|
|
58
|
+
let run = flashStateByRun.get(researchId);
|
|
59
|
+
if (!run) {
|
|
60
|
+
run = new Map();
|
|
61
|
+
flashStateByRun.set(researchId, run);
|
|
62
|
+
}
|
|
63
|
+
let slice = run.get(sliceId);
|
|
64
|
+
if (!slice) {
|
|
65
|
+
slice = {
|
|
66
|
+
activeColor: null,
|
|
67
|
+
activeTimeout: null,
|
|
68
|
+
colorQueue: [],
|
|
69
|
+
activeStatus: null,
|
|
70
|
+
statusTimeout: null,
|
|
71
|
+
statusQueue: []
|
|
72
|
+
};
|
|
73
|
+
run.set(sliceId, slice);
|
|
74
|
+
}
|
|
75
|
+
return slice;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Clear all flash state for a research run (call on that run's teardown). Scoped to
|
|
80
|
+
* the run so disposing one run never cancels a concurrent sibling run's flashes.
|
|
81
|
+
*/
|
|
82
|
+
export function clearAllFlashTimeouts(researchId?: string): void {
|
|
83
|
+
if (researchId) {
|
|
84
|
+
const run = flashStateByRun.get(researchId);
|
|
85
|
+
if (run) {
|
|
86
|
+
for (const slice of run.values()) {
|
|
87
|
+
if (slice.activeTimeout) clearTimeout(slice.activeTimeout);
|
|
88
|
+
if (slice.statusTimeout) clearTimeout(slice.statusTimeout);
|
|
89
|
+
slice.activeTimeout = null;
|
|
90
|
+
slice.activeColor = null;
|
|
91
|
+
slice.colorQueue.length = 0;
|
|
92
|
+
slice.statusTimeout = null;
|
|
93
|
+
slice.activeStatus = null;
|
|
94
|
+
slice.statusQueue.length = 0;
|
|
95
|
+
}
|
|
96
|
+
flashStateByRun.delete(researchId);
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
for (const run of flashStateByRun.values()) {
|
|
100
|
+
for (const slice of run.values()) {
|
|
101
|
+
if (slice.activeTimeout) clearTimeout(slice.activeTimeout);
|
|
102
|
+
if (slice.statusTimeout) clearTimeout(slice.statusTimeout);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
flashStateByRun.clear();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Flash a researcher slice green or red.
|
|
111
|
+
*
|
|
112
|
+
* If a flash or gap is currently active for this slice the new entry is queued.
|
|
113
|
+
* Each queued flash plays after the previous one expires plus a small gap
|
|
114
|
+
* so rapid-fire per-URL results are visible as distinct pulses.
|
|
115
|
+
*/
|
|
116
|
+
export function flashSlice(
|
|
117
|
+
state: ResearchPanelState,
|
|
118
|
+
sliceId: string,
|
|
119
|
+
color: 'green' | 'red',
|
|
120
|
+
onUpdate?: () => void,
|
|
121
|
+
): void {
|
|
122
|
+
const slice = state.slices.get(sliceId);
|
|
123
|
+
if (!slice || slice.completed || slice.queued) return;
|
|
124
|
+
|
|
125
|
+
const flash = getOrCreateSliceFlash(state.researchId, sliceId);
|
|
126
|
+
|
|
127
|
+
if (flash.activeTimeout !== null) {
|
|
128
|
+
if (flash.colorQueue.length < MAX_QUEUE_SIZE) {
|
|
129
|
+
flash.colorQueue.push({ color });
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
startFlash(state, sliceId, flash, color, onUpdate);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function startFlash(
|
|
138
|
+
state: ResearchPanelState,
|
|
139
|
+
sliceId: string,
|
|
140
|
+
flash: SliceFlashState,
|
|
141
|
+
color: 'green' | 'red',
|
|
142
|
+
onUpdate?: () => void,
|
|
143
|
+
): void {
|
|
144
|
+
const slice = state.slices.get(sliceId);
|
|
145
|
+
if (!slice || slice.completed) return;
|
|
146
|
+
|
|
147
|
+
const duration = color === 'green' ? FLASH_GREEN_DURATION_MS : FLASH_RED_DURATION_MS;
|
|
148
|
+
|
|
149
|
+
flash.activeColor = color;
|
|
150
|
+
slice.flash = color;
|
|
151
|
+
onUpdate?.();
|
|
152
|
+
|
|
153
|
+
flash.activeTimeout = setTimeout(() => {
|
|
154
|
+
flash.activeColor = null;
|
|
155
|
+
flash.activeTimeout = null;
|
|
156
|
+
|
|
157
|
+
const currentSlice = state.slices.get(sliceId);
|
|
158
|
+
if (currentSlice) currentSlice.flash = null;
|
|
159
|
+
|
|
160
|
+
if (flash.colorQueue.length > 0) {
|
|
161
|
+
const currentSliceCheck = state.slices.get(sliceId);
|
|
162
|
+
if (currentSliceCheck && !currentSliceCheck.completed) {
|
|
163
|
+
const next = flash.colorQueue.shift()!;
|
|
164
|
+
flash.activeTimeout = setTimeout(() => {
|
|
165
|
+
startFlash(state, sliceId, flash, next.color, onUpdate);
|
|
166
|
+
}, FLASH_QUEUE_GAP_MS);
|
|
167
|
+
onUpdate?.();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
onUpdate?.();
|
|
172
|
+
}, duration);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Flash a status message (tool name) in the researcher box for a fixed duration.
|
|
177
|
+
*/
|
|
178
|
+
export function flashStatus(
|
|
179
|
+
state: ResearchPanelState,
|
|
180
|
+
sliceId: string,
|
|
181
|
+
status: string,
|
|
182
|
+
onUpdate?: () => void,
|
|
183
|
+
): void {
|
|
184
|
+
const slice = state.slices.get(sliceId);
|
|
185
|
+
if (!slice || slice.completed || slice.queued) return;
|
|
186
|
+
|
|
187
|
+
const flash = getOrCreateSliceFlash(state.researchId, sliceId);
|
|
188
|
+
|
|
189
|
+
if (flash.statusTimeout !== null) {
|
|
190
|
+
if (flash.statusQueue.length < MAX_QUEUE_SIZE) {
|
|
191
|
+
flash.statusQueue.push(status);
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
startStatusPop(state, sliceId, flash, status, onUpdate);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function startStatusPop(
|
|
200
|
+
state: ResearchPanelState,
|
|
201
|
+
sliceId: string,
|
|
202
|
+
flash: SliceFlashState,
|
|
203
|
+
status: string,
|
|
204
|
+
onUpdate?: () => void,
|
|
205
|
+
): void {
|
|
206
|
+
const slice = state.slices.get(sliceId);
|
|
207
|
+
if (!slice || slice.completed) return;
|
|
208
|
+
|
|
209
|
+
flash.activeStatus = status;
|
|
210
|
+
slice.status = status;
|
|
211
|
+
onUpdate?.();
|
|
212
|
+
|
|
213
|
+
flash.statusTimeout = setTimeout(() => {
|
|
214
|
+
flash.activeStatus = null;
|
|
215
|
+
flash.statusTimeout = null;
|
|
216
|
+
|
|
217
|
+
const currentSlice = state.slices.get(sliceId);
|
|
218
|
+
if (currentSlice) currentSlice.status = undefined;
|
|
219
|
+
|
|
220
|
+
if (flash.statusQueue.length > 0) {
|
|
221
|
+
const currentSliceCheck = state.slices.get(sliceId);
|
|
222
|
+
if (currentSliceCheck && !currentSliceCheck.completed) {
|
|
223
|
+
const next = flash.statusQueue.shift()!;
|
|
224
|
+
flash.statusTimeout = setTimeout(() => {
|
|
225
|
+
startStatusPop(state, sliceId, flash, next, onUpdate);
|
|
226
|
+
}, FLASH_QUEUE_GAP_MS);
|
|
227
|
+
onUpdate?.();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
onUpdate?.();
|
|
232
|
+
}, FLASH_STATUS_DURATION_MS);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Clean up flash state for a specific slice.
|
|
237
|
+
* Clears timeout, queue, and removes from the session map.
|
|
238
|
+
*/
|
|
239
|
+
function clearSliceFlash(researchId: string, sliceId: string): void {
|
|
240
|
+
const run = flashStateByRun.get(researchId);
|
|
241
|
+
if (!run) return;
|
|
242
|
+
const flash = run.get(sliceId);
|
|
243
|
+
if (!flash) return;
|
|
244
|
+
if (flash.activeTimeout) clearTimeout(flash.activeTimeout);
|
|
245
|
+
if (flash.statusTimeout) clearTimeout(flash.statusTimeout);
|
|
246
|
+
flash.activeTimeout = null;
|
|
247
|
+
flash.activeColor = null;
|
|
248
|
+
flash.colorQueue.length = 0;
|
|
249
|
+
flash.statusTimeout = null;
|
|
250
|
+
flash.activeStatus = null;
|
|
251
|
+
flash.statusQueue.length = 0;
|
|
252
|
+
run.delete(sliceId);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Cancel any in-flight or queued status "pop" for a slice, WITHOUT touching its
|
|
257
|
+
* color flash or removing the slice's flash record. Call this on an explicit status
|
|
258
|
+
* clear (status === undefined) that bypasses the timed-pop queue — otherwise a
|
|
259
|
+
* still-armed flashStatus() timer from the just-cleared status survives the clear,
|
|
260
|
+
* and the next flashStatus() for a NEW status sees that stale timer as "still
|
|
261
|
+
* active" and queues instead of showing immediately; when the stale timer fires it
|
|
262
|
+
* pops the queued status late, disconnected from when its tool started (a
|
|
263
|
+
* flickering/laggy label, worst on deep-mode's several concurrent researcher boxes).
|
|
264
|
+
*/
|
|
265
|
+
function clearSliceStatusFlash(researchId: string, sliceId: string): void {
|
|
266
|
+
const flash = flashStateByRun.get(researchId)?.get(sliceId);
|
|
267
|
+
if (!flash) return;
|
|
268
|
+
if (flash.statusTimeout) clearTimeout(flash.statusTimeout);
|
|
269
|
+
flash.statusTimeout = null;
|
|
270
|
+
flash.activeStatus = null;
|
|
271
|
+
flash.statusQueue.length = 0;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// Slice lifecycle
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Add a new researcher column.
|
|
280
|
+
* If a slice with this id already exists, any leftover flash state is cleaned up.
|
|
281
|
+
*/
|
|
282
|
+
export function addSlice(state: ResearchPanelState, id: string, label: string, queued: boolean = false): void {
|
|
283
|
+
// Clean up any leftover flash state from a previous slice with the same id
|
|
284
|
+
// (e.g., 'eval' being re-added across evaluation rounds).
|
|
285
|
+
clearSliceFlash(state.researchId, id);
|
|
286
|
+
state.slices.set(id, { id, label, completed: false, queued, flash: null });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Remove a researcher column.
|
|
291
|
+
* Cleans up any flash state to prevent orphaned timers.
|
|
292
|
+
*/
|
|
293
|
+
export function removeSlice(state: ResearchPanelState, id: string): void {
|
|
294
|
+
clearSliceFlash(state.researchId, id);
|
|
295
|
+
state.slices.delete(id);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Mark researcher as active (start from queued state)
|
|
300
|
+
*/
|
|
301
|
+
export function activateSlice(state: ResearchPanelState, id: string): void {
|
|
302
|
+
const slice = state.slices.get(id);
|
|
303
|
+
if (slice) slice.queued = false;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Update researcher tokens and cost.
|
|
308
|
+
* Tokens are treated as current context size (latest value), while cost is accumulated.
|
|
309
|
+
*/
|
|
310
|
+
export function updateSliceTokens(state: ResearchPanelState, id: string, tokens: number, cost: number): void {
|
|
311
|
+
const slice = state.slices.get(id);
|
|
312
|
+
if (slice) {
|
|
313
|
+
// Non-decreasing guard: never update with a lower token count (stale estimates)
|
|
314
|
+
if (tokens > (slice.tokens || 0)) {
|
|
315
|
+
slice.tokens = tokens;
|
|
316
|
+
}
|
|
317
|
+
// Cost is accumulated by adding the new cost to the existing cost
|
|
318
|
+
slice.cost = (slice.cost || 0) + cost;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Update researcher status message (e.g. "Searching...")
|
|
324
|
+
* For researchers, this uses flashStatus to provide timed "pops" of tool names.
|
|
325
|
+
*/
|
|
326
|
+
export function updateSliceStatus(state: ResearchPanelState, id: string, status: string | undefined, onUpdate?: () => void): void {
|
|
327
|
+
const slice = state.slices.get(id);
|
|
328
|
+
if (!slice) return;
|
|
329
|
+
|
|
330
|
+
// For researchers (numeric labels or 'quick' slice), use the timed pop logic
|
|
331
|
+
// for non-empty status strings. This makes fast tool names visible.
|
|
332
|
+
// Core agents (coord/eval) keep persistent statuses for long-running phases.
|
|
333
|
+
const isResearcher = !isNaN(parseInt(slice.label, 10)) || slice.id.includes('researching:');
|
|
334
|
+
|
|
335
|
+
if (status && isResearcher) {
|
|
336
|
+
flashStatus(state, id, status, onUpdate);
|
|
337
|
+
} else {
|
|
338
|
+
// Explicit clear (or a coord/eval persistent write) bypasses the timed-pop
|
|
339
|
+
// queue. For a researcher slice, cancel any pending flashStatus() timer for
|
|
340
|
+
// THIS slice so a stale timer can't delay-pop a status queued after this clear.
|
|
341
|
+
if (isResearcher) clearSliceStatusFlash(state.researchId, id);
|
|
342
|
+
slice.status = status;
|
|
343
|
+
if (onUpdate) onUpdate();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Mark researcher as complete. Pass `failed = true` to give it a terminal failed
|
|
349
|
+
* outcome (rendered distinctly from a successful completion).
|
|
350
|
+
*/
|
|
351
|
+
export function completeSlice(state: ResearchPanelState, id: string, failed = false): void {
|
|
352
|
+
const slice = state.slices.get(id);
|
|
353
|
+
if (slice) {
|
|
354
|
+
slice.completed = true;
|
|
355
|
+
slice.queued = false;
|
|
356
|
+
slice.flash = null;
|
|
357
|
+
if (failed) slice.failed = true;
|
|
358
|
+
clearSliceFlash(state.researchId, id);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Re-mark a researcher as active (used when promoting a completed researcher to lead evaluator).
|
|
364
|
+
* Cleans up any leftover flash state from the previous lifecycle phase.
|
|
365
|
+
*/
|
|
366
|
+
export function reactivateSlice(state: ResearchPanelState, id: string): void {
|
|
367
|
+
// Clean up any flash state from the previous lifecycle (timers, queue).
|
|
368
|
+
clearSliceFlash(state.researchId, id);
|
|
369
|
+
const slice = state.slices.get(id);
|
|
370
|
+
if (slice) {
|
|
371
|
+
slice.completed = false;
|
|
372
|
+
slice.flash = null;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Clear all completed slices from the TUI panel.
|
|
378
|
+
* This includes researchers, coordinator, and evaluator once completed.
|
|
379
|
+
*/
|
|
380
|
+
export function clearCompletedResearchers(state: ResearchPanelState): void {
|
|
381
|
+
const toRemove: string[] = [];
|
|
382
|
+
for (const [id, slice] of state.slices.entries()) {
|
|
383
|
+
if (slice.completed) {
|
|
384
|
+
clearSliceFlash(state.researchId, id);
|
|
385
|
+
toRemove.push(id);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
for (const id of toRemove) {
|
|
389
|
+
state.slices.delete(id);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Create initial panel state for research session
|
|
395
|
+
*/
|
|
396
|
+
export function createInitialPanelState(sessionId: string, researchId: string, query: string, modelName: string): ResearchPanelState {
|
|
397
|
+
return {
|
|
398
|
+
sessionId,
|
|
399
|
+
researchId,
|
|
400
|
+
query,
|
|
401
|
+
slices: new Map(),
|
|
402
|
+
modelName,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Research Panel Wave Animation
|
|
3
|
+
*
|
|
4
|
+
* Head-trails gradient for the research panel header.
|
|
5
|
+
*
|
|
6
|
+
* Mechanic:
|
|
7
|
+
*
|
|
8
|
+
* HEAD: always full accent brightness.
|
|
9
|
+
* Character is a directional half-dash:
|
|
10
|
+
* ╶ (right half) while moving left→right
|
|
11
|
+
* ╴ (left half) while moving right→left
|
|
12
|
+
*
|
|
13
|
+
* TRAIL: each position stores the slowFrame when it was last visited by the
|
|
14
|
+
* head. On every rendered frame, age = currentSlowFrame − lastVisitFrame[i].
|
|
15
|
+
* That age is mapped through an easeIn brightness ramp (power 2.2) over a
|
|
16
|
+
* window of TRAIL_LEN = ¾ of available chars:
|
|
17
|
+
*
|
|
18
|
+
* age 0 → full accent brightness (head just left)
|
|
19
|
+
* age TRAIL_LEN-1 → 65 % brightness floor (far edge of trail)
|
|
20
|
+
* age >= TRAIL_LEN → dark floor (clamped)
|
|
21
|
+
*
|
|
22
|
+
* DWELL: the head pauses DWELL=3 slow-frames at both the left and right edge
|
|
23
|
+
* before reversing, giving a consistent rest at each end.
|
|
24
|
+
*
|
|
25
|
+
* Speed factor 0.87 retained from the original commit.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { Theme } from '../types/research-panel-types.ts';
|
|
29
|
+
import {
|
|
30
|
+
parseAnsiFgColor,
|
|
31
|
+
indexToRgb,
|
|
32
|
+
rgbToHsl,
|
|
33
|
+
hslToRgb,
|
|
34
|
+
} from './research-panel-color-utils.ts';
|
|
35
|
+
|
|
36
|
+
// Geometry is expressed in pulse frames. The pulse runs at ~30 FPS
|
|
37
|
+
// (33ms/frame, see pulse.ts) — one cell-step per frame, which is the natural
|
|
38
|
+
// visual resolution for a one-cell-wide head and reads cleaner than 60 FPS
|
|
39
|
+
// (see pulse.ts for why higher FPS adds jitter without smoothing the motion).
|
|
40
|
+
const DWELL_FRAMES = 10; // pause duration at each edge (~330ms at 33ms/frame)
|
|
41
|
+
const PERIOD_FRAMES = 98; // total frames for one full back-and-forth cycle (~3.2s at 33ms/frame)
|
|
42
|
+
|
|
43
|
+
// Scaling constants for "static-ish" tail distance
|
|
44
|
+
const REFERENCE_WIDTH = 80;
|
|
45
|
+
const REFERENCE_TAIL_DIST = 40;
|
|
46
|
+
const TAIL_SCALING_EXPONENT = 0.5;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the theme accent colour to a full-precision RGB triple.
|
|
50
|
+
*/
|
|
51
|
+
function getBaseAccentRgb(theme: Theme): { r: number; g: number; b: number } {
|
|
52
|
+
const accentText = theme.fg('accent', '');
|
|
53
|
+
const parsed = parseAnsiFgColor(accentText);
|
|
54
|
+
|
|
55
|
+
if (parsed) {
|
|
56
|
+
if (parsed.type === 'rgb' && parsed.r !== undefined && parsed.g !== undefined && parsed.b !== undefined) {
|
|
57
|
+
return { r: parsed.r, g: parsed.g, b: parsed.b };
|
|
58
|
+
}
|
|
59
|
+
if (parsed.type === '256' && parsed.index !== undefined) {
|
|
60
|
+
const idx = parsed.index;
|
|
61
|
+
if (idx >= 16 && idx <= 231) {
|
|
62
|
+
const rgb = indexToRgb(idx);
|
|
63
|
+
if (rgb) return rgb;
|
|
64
|
+
} else if (idx >= 232 && idx <= 255) {
|
|
65
|
+
const gray = 8 + (idx - 232) * 10;
|
|
66
|
+
return { r: gray, g: gray, b: gray };
|
|
67
|
+
} else if (idx >= 0 && idx <= 15) {
|
|
68
|
+
const ansi16: Record<number, { r: number; g: number; b: number }> = {
|
|
69
|
+
0: { r: 0, g: 0, b: 0 },
|
|
70
|
+
1: { r: 170, g: 0, b: 0 },
|
|
71
|
+
2: { r: 0, g: 170, b: 0 },
|
|
72
|
+
3: { r: 170, g: 170, b: 0 },
|
|
73
|
+
4: { r: 0, g: 0, b: 170 },
|
|
74
|
+
5: { r: 170, g: 0, b: 170 },
|
|
75
|
+
6: { r: 0, g: 170, b: 170 },
|
|
76
|
+
7: { r: 170, g: 170, b: 170 },
|
|
77
|
+
8: { r: 85, g: 85, b: 85 },
|
|
78
|
+
9: { r: 255, g: 85, b: 85 },
|
|
79
|
+
10: { r: 85, g: 255, b: 85 },
|
|
80
|
+
11: { r: 255, g: 255, b: 85 },
|
|
81
|
+
12: { r: 85, g: 85, b: 255 },
|
|
82
|
+
13: { r: 255, g: 85, b: 255 },
|
|
83
|
+
14: { r: 85, g: 255, b: 255 },
|
|
84
|
+
15: { r: 255, g: 255, b: 255 },
|
|
85
|
+
};
|
|
86
|
+
const c = ansi16[idx];
|
|
87
|
+
if (c) return c;
|
|
88
|
+
}
|
|
89
|
+
} else if (parsed.type === 'basic' && parsed.index !== undefined) {
|
|
90
|
+
const basic: Record<number, { r: number; g: number; b: number }> = {
|
|
91
|
+
0: { r: 0, g: 0, b: 0 },
|
|
92
|
+
1: { r: 170, g: 0, b: 0 },
|
|
93
|
+
2: { r: 0, g: 170, b: 0 },
|
|
94
|
+
3: { r: 170, g: 170, b: 0 },
|
|
95
|
+
4: { r: 0, g: 0, b: 170 },
|
|
96
|
+
5: { r: 170, g: 0, b: 170 },
|
|
97
|
+
6: { r: 0, g: 170, b: 170 },
|
|
98
|
+
7: { r: 170, g: 170, b: 170 },
|
|
99
|
+
};
|
|
100
|
+
const c = basic[parsed.index];
|
|
101
|
+
if (c) return c;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { r: 148, g: 148, b: 148 };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Compute the color for a given age within the trail window.
|
|
110
|
+
*
|
|
111
|
+
* age = 0 → full accent brightness (head just visited)
|
|
112
|
+
* age = trailLen - 1 → 65 % brightness (dark floor of trail)
|
|
113
|
+
* age >= trailLen → clamped to dark floor
|
|
114
|
+
*
|
|
115
|
+
* easeIn power-curve 2.2 for perceptually even brightness distribution.
|
|
116
|
+
*/
|
|
117
|
+
function ageToColor(
|
|
118
|
+
hsl: { h: number; s: number; l: number },
|
|
119
|
+
age: number,
|
|
120
|
+
trailLen: number,
|
|
121
|
+
): { r: number; g: number; b: number } {
|
|
122
|
+
const clamped = Math.min(age, trailLen - 1);
|
|
123
|
+
const progress = Math.max(0, clamped / Math.max(1, trailLen - 1)); // 0.0 (bright) … 1.0 (dark)
|
|
124
|
+
const linearFactor = 1 - progress;
|
|
125
|
+
const curvedFactor = Math.pow(linearFactor, 2.2); // easeIn
|
|
126
|
+
|
|
127
|
+
const newL = hsl.l * (0.65 + 0.35 * curvedFactor);
|
|
128
|
+
const newS = hsl.s * (0.98 + 0.02 * curvedFactor);
|
|
129
|
+
return hslToRgb(hsl.h, newS, newL);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Compute head position and state given a frame counter and width.
|
|
134
|
+
*
|
|
135
|
+
* Returns { headPos, goingRight, isDwelling }
|
|
136
|
+
* Constant duration: total period is fixed, speed scales with available width.
|
|
137
|
+
* headPos is returned as a float for smooth interpolation.
|
|
138
|
+
*/
|
|
139
|
+
function computeHeadState(frame: number, available: number): { headPos: number; goingRight: boolean; isDwelling: boolean } {
|
|
140
|
+
const M = Math.max(1, available - 1);
|
|
141
|
+
const D = DWELL_FRAMES;
|
|
142
|
+
const halfPeriod = PERIOD_FRAMES / 2;
|
|
143
|
+
const pos = frame % PERIOD_FRAMES;
|
|
144
|
+
|
|
145
|
+
if (pos < D) {
|
|
146
|
+
// Left dwell
|
|
147
|
+
return { headPos: 0, goingRight: true, isDwelling: true };
|
|
148
|
+
} else if (pos < halfPeriod) {
|
|
149
|
+
// Traversing left→right
|
|
150
|
+
const traverseFrames = halfPeriod - D;
|
|
151
|
+
const progress = (pos - D) / traverseFrames;
|
|
152
|
+
return { headPos: progress * M, goingRight: true, isDwelling: false };
|
|
153
|
+
} else if (pos < halfPeriod + D) {
|
|
154
|
+
// Right dwell
|
|
155
|
+
return { headPos: M, goingRight: false, isDwelling: true };
|
|
156
|
+
} else {
|
|
157
|
+
// Traversing right→left
|
|
158
|
+
const traverseFrames = PERIOD_FRAMES - (halfPeriod + D);
|
|
159
|
+
const progress = (pos - (halfPeriod + D)) / traverseFrames;
|
|
160
|
+
return { headPos: M - (progress * M), goingRight: false, isDwelling: false };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Generate the head-trails gradient wave fill string for one frame.
|
|
166
|
+
*
|
|
167
|
+
* waveColors stores the waveFrame when each position was last visited by the
|
|
168
|
+
* head. On each call the head's current position is updated, and every
|
|
169
|
+
* position is colored based on its decay age since the last head visit.
|
|
170
|
+
*
|
|
171
|
+
* updatedColors must be stored by the caller (panel.waveColors = updatedColors).
|
|
172
|
+
*
|
|
173
|
+
* @param theme - current theme (provides accent colour)
|
|
174
|
+
* @param waveFrame - monotonically increasing frame counter
|
|
175
|
+
* @param waveColors - previously returned updatedColors (or undefined on first call)
|
|
176
|
+
* @param available - number of characters to fill
|
|
177
|
+
*/
|
|
178
|
+
export function generateWaveFill(
|
|
179
|
+
theme: Theme,
|
|
180
|
+
waveFrame: number,
|
|
181
|
+
waveColors: number[] | undefined,
|
|
182
|
+
available: number
|
|
183
|
+
): { fill: string; updatedColors: number[] } {
|
|
184
|
+
if (available <= 0) return { fill: '', updatedColors: [] };
|
|
185
|
+
|
|
186
|
+
const base = getBaseAccentRgb(theme);
|
|
187
|
+
const hsl = rgbToHsl(base.r, base.g, base.b);
|
|
188
|
+
const resetFg = '\x1b[39m';
|
|
189
|
+
|
|
190
|
+
// Calculate spatial tail distance based on width using a square-root scaling law.
|
|
191
|
+
// This ensures the tail grows/shrinks slower than the width itself,
|
|
192
|
+
// providing a more "static" and consistent feel across terminal sizes.
|
|
193
|
+
const tailDistChars = REFERENCE_TAIL_DIST * Math.pow(available / REFERENCE_WIDTH, TAIL_SCALING_EXPONENT);
|
|
194
|
+
|
|
195
|
+
// Speed in chars/frame: distance covered in one traversal window.
|
|
196
|
+
const traverseFrames = (PERIOD_FRAMES / 2) - DWELL_FRAMES;
|
|
197
|
+
const speed = (available - 1) / Math.max(1, traverseFrames);
|
|
198
|
+
|
|
199
|
+
// Trail length in frames = distance / speed.
|
|
200
|
+
// This translates the desired spatial distance into the temporal decay window.
|
|
201
|
+
const trailLen = Math.max(2, tailDistChars / Math.max(0.1, speed));
|
|
202
|
+
|
|
203
|
+
// Initialise or resize: place last-visit far enough in the past that all
|
|
204
|
+
// positions start at the dark floor.
|
|
205
|
+
if (!waveColors || waveColors.length !== available) {
|
|
206
|
+
const neverVisited = waveFrame - trailLen;
|
|
207
|
+
waveColors = Array(available).fill(neverVisited) as number[];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const { headPos, goingRight, isDwelling } = computeHeadState(waveFrame, available);
|
|
211
|
+
const headIdx = Math.floor(headPos);
|
|
212
|
+
|
|
213
|
+
// Paint the trail: ensure every segment passed between the previous frame
|
|
214
|
+
// and this one is colored. We use fractional visit-times for smooth gradients
|
|
215
|
+
// even when the head moves >1 char per frame (common on wide terminals).
|
|
216
|
+
if (waveFrame > 0) {
|
|
217
|
+
const prev = computeHeadState(waveFrame - 1, available);
|
|
218
|
+
const startIdx = Math.min(Math.floor(prev.headPos), headIdx);
|
|
219
|
+
const endIdx = Math.max(Math.floor(prev.headPos), headIdx);
|
|
220
|
+
const dist = Math.abs(headPos - prev.headPos);
|
|
221
|
+
|
|
222
|
+
if (dist > 0.001) {
|
|
223
|
+
for (let i = startIdx; i <= endIdx; i++) {
|
|
224
|
+
// visitTime = (previous frame time) + (fraction of traversal to index i)
|
|
225
|
+
const d = Math.abs(i - prev.headPos);
|
|
226
|
+
const visitTime = (waveFrame - 1) + (d / dist);
|
|
227
|
+
waveColors[i] = visitTime;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// Ensure the head's discrete position is always set to the current frame time
|
|
231
|
+
// for maximum brightness at the leading edge.
|
|
232
|
+
waveColors[headIdx] = waveFrame;
|
|
233
|
+
} else {
|
|
234
|
+
waveColors[headIdx] = waveFrame;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Directional head character: half-dash pointing in the direction of motion
|
|
238
|
+
// ╴ (U+2574, BOX DRAWINGS LIGHT LEFT) while going left→right (connects to trail on left)
|
|
239
|
+
// ╶ (U+2576, BOX DRAWINGS LIGHT RIGHT) while going right→left (connects to trail on right)
|
|
240
|
+
let headChar = goingRight ? '╴' : '╶';
|
|
241
|
+
|
|
242
|
+
// Solid head when dwelling (no gap)
|
|
243
|
+
if (isDwelling) {
|
|
244
|
+
headChar = (headIdx === 0) ? '╶' : '─';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Ensure head doesn't point into space at index 0 when starting movement
|
|
248
|
+
if (headIdx === 0 && goingRight) {
|
|
249
|
+
headChar = '╶';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Special edge hit characters (1-frame impact) override solid head
|
|
253
|
+
const halfPeriod = PERIOD_FRAMES / 2;
|
|
254
|
+
const pos = waveFrame % PERIOD_FRAMES;
|
|
255
|
+
|
|
256
|
+
if (pos === halfPeriod) {
|
|
257
|
+
headChar = '╼'; // Hit right edge (LIGHT LEFT AND HEAVY RIGHT)
|
|
258
|
+
} else if (pos === 0) {
|
|
259
|
+
headChar = '╺'; // Hit left edge (HEAVY RIGHT)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Build fill from per-position ages
|
|
263
|
+
let fill = '';
|
|
264
|
+
for (let i = 0; i < available; i++) {
|
|
265
|
+
const lastVisit = waveColors[i] ?? (waveFrame - trailLen);
|
|
266
|
+
const age = waveFrame - lastVisit;
|
|
267
|
+
|
|
268
|
+
const rgb = ageToColor(hsl, age, trailLen);
|
|
269
|
+
const color = `\x1b[38;2;${rgb.r};${rgb.g};${rgb.b}m`;
|
|
270
|
+
|
|
271
|
+
// Trail at index 0 should be rounded '╶' to match header style
|
|
272
|
+
let char = (i === 0) ? '╶' : '─';
|
|
273
|
+
if (i === headIdx) char = headChar;
|
|
274
|
+
|
|
275
|
+
fill += `${color}${char}${resetFg}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return { fill, updatedColors: waveColors };
|
|
279
|
+
}
|
|
280
|
+
|