@fyeeme/pi-thinking-ui 1.0.1 → 1.1.1

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/render.ts DELETED
@@ -1,488 +0,0 @@
1
- import type { Component } from "@earendil-works/pi-tui";
2
- import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
- import { deriveStepCore, deriveThinkingUI } from "./parse.ts";
4
- import type { DerivedStepCore } from "./parse.ts";
5
- import { getActiveThinkingState, getCurrentThinkingScopeKey, getThinkingUIMode } from "./state.ts";
6
- import type { DerivedThinkingStep, ThinkingSemanticRole, ThinkingSourceBlock, ThinkingThemeLike } from "./types.ts";
7
-
8
- interface RenderOptions {
9
- mode: "collapsed" | "summary" | "expanded";
10
- steps: DerivedThinkingStep[];
11
- activeStepId?: string;
12
- isActive: boolean;
13
- nowMs?: number;
14
- }
15
-
16
- function roleColor(role: ThinkingSemanticRole): string {
17
- switch (role) {
18
- case "verify":
19
- return "success";
20
- case "error":
21
- return "error";
22
- case "compare":
23
- return "warning";
24
- case "inspect":
25
- case "search":
26
- return "mdLink";
27
- case "write":
28
- case "plan":
29
- return "accent";
30
- default:
31
- return "muted";
32
- }
33
- }
34
-
35
- function pulseGlyph(theme: ThinkingThemeLike, nowMs: number): string {
36
- const frames = [
37
- theme.fg("dim", "·"),
38
- theme.fg("muted", "•"),
39
- theme.fg("accent", "•"),
40
- theme.fg("muted", "•"),
41
- ];
42
- const frame = Math.floor(nowMs / 180) % frames.length;
43
- return frames[frame] ?? frames[0]!;
44
- }
45
-
46
- type InlineSegmentStyle = "plain" | "bold" | "code";
47
-
48
- interface InlineSegment {
49
- text: string;
50
- style: InlineSegmentStyle;
51
- }
52
-
53
- function sanitizeThinkingText(text: string): string {
54
- return text
55
- .replace(/\r\n?/g, "\n")
56
- .replace(/\u001b[\]PX^_][\s\S]*?(?:\u0007|\u001b\\|\u009c)/g, "")
57
- .replace(/[\u0090\u0098\u009d\u009e\u009f][\s\S]*?(?:\u0007|\u001b\\|\u009c)/g, "")
58
- .replace(/\u001b(?:\[[0-?]*[ -/]*[@-~]|[ -/]*[0-9@-~])/g, "")
59
- .replace(/\u009b[0-?]*[ -/]*[@-~]/g, "")
60
- .replace(/[\x00-\x08\x0B-\x1F\x7F-\x9F]/g, "");
61
- }
62
-
63
- function parseThinkingInlineSegments(text: string): InlineSegment[] {
64
- const sanitized = sanitizeThinkingText(text);
65
- const segments: InlineSegment[] = [];
66
- const markerRe = /(\*\*|__)(?=\S)([\s\S]*?\S)\1|`([^`]+)`|(?<![\w/.-])\*(?!\*)(?=\S)([\s\S]*?\S)(?<!\*)\*(?![\w/.-])|(?<![\w/.-])_(?!_)(?=\S)([\s\S]*?\S)(?<!_)_(?![\w/.-])/g;
67
- let lastIndex = 0;
68
- for (const match of sanitized.matchAll(markerRe)) {
69
- const markerIndex = match.index ?? 0;
70
- if (markerIndex > lastIndex) {
71
- segments.push({ text: sanitized.slice(lastIndex, markerIndex), style: "plain" });
72
- }
73
- if (match[2]) segments.push({ text: match[2], style: "bold" });
74
- if (match[3]) segments.push({ text: match[3], style: "code" });
75
- if (match[4]) segments.push({ text: match[4], style: "plain" });
76
- if (match[5]) segments.push({ text: match[5], style: "plain" });
77
- lastIndex = markerIndex + match[0].length;
78
- }
79
- if (lastIndex < sanitized.length) {
80
- segments.push({ text: sanitized.slice(lastIndex), style: "plain" });
81
- }
82
- return segments;
83
- }
84
-
85
- function renderThinkingInlineSegment(theme: ThinkingThemeLike, segment: InlineSegment): string {
86
- if (segment.style === "bold") return theme.bold(theme.fg("thinkingText", segment.text));
87
- if (segment.style === "code") return theme.bold(theme.fg("accent", segment.text));
88
- return theme.fg("thinkingText", segment.text);
89
- }
90
-
91
- function wrapStepHeader(theme: ThinkingThemeLike, width: number, step: DerivedThinkingStep, active: boolean, connector: string): string[] {
92
- const connectorColor = active ? "accent" : "muted";
93
- const icon = theme.fg(roleColor(step.role), step.icon);
94
- const prefix = `${theme.fg(connectorColor, connector)} ${icon} `;
95
- const continuationPrefix = " ".repeat(visibleWidth(`${connector} ${step.icon} `));
96
- const renderedSummary = renderThinkingInlineMarkup(theme, step.summary);
97
- const summaryText = active ? theme.bold(renderedSummary) : renderedSummary;
98
- const wrappedSummary = wrapTextWithAnsi(summaryText, Math.max(8, width - visibleWidth(prefix)));
99
- if (wrappedSummary.length === 0) {
100
- return [truncateToWidth(prefix, width, "")];
101
- }
102
-
103
- return wrappedSummary.map((line, index) =>
104
- truncateToWidth(`${index === 0 ? prefix : continuationPrefix}${line}`, width, ""),
105
- );
106
- }
107
-
108
- function pickCollapsedStep(steps: DerivedThinkingStep[], activeStepId?: string): DerivedThinkingStep | undefined {
109
- if (steps.length === 0) return undefined;
110
- if (activeStepId) {
111
- const active = steps.find((step) => step.id === activeStepId);
112
- if (active) return active;
113
- }
114
-
115
- let latestFailureIndex = -1;
116
- let latestSuccessAfterFailureIndex = -1;
117
-
118
- for (let index = 0; index < steps.length; index += 1) {
119
- const step = steps[index]!;
120
- if (step.hasExplicitFailure) {
121
- latestFailureIndex = index;
122
- latestSuccessAfterFailureIndex = -1;
123
- }
124
- if (latestFailureIndex !== -1 && step.hasExplicitSuccess && index > latestFailureIndex) {
125
- latestSuccessAfterFailureIndex = index;
126
- }
127
- }
128
-
129
- if (latestSuccessAfterFailureIndex !== -1) return steps[latestSuccessAfterFailureIndex];
130
- if (latestFailureIndex !== -1) return steps[latestFailureIndex];
131
-
132
- return [...steps]
133
- .sort((left, right) => (right.collapsedPriority ?? 0) - (left.collapsedPriority ?? 0) || right.blockIndex - left.blockIndex || right.stepIndex - left.stepIndex)[0];
134
- }
135
- function wrapCollapsedSummaryText(theme: ThinkingThemeLike, text: string, firstWidth: number, continuationWidth: number): string[] {
136
- const words = parseThinkingInlineSegments(text).flatMap((segment) =>
137
- segment.text
138
- .split(/\s+/)
139
- .filter(Boolean)
140
- .map((word) => renderThinkingInlineSegment(theme, { ...segment, text: word })),
141
- );
142
- if (words.length === 0) return [];
143
-
144
- const lines: string[] = [];
145
- let current = "";
146
- let currentWidth = Math.max(8, firstWidth);
147
- const continuationLineWidth = () => Math.max(8, continuationWidth);
148
-
149
- for (const word of words) {
150
- let pending = word;
151
- while (pending.length > 0) {
152
- const candidate = current ? `${current} ${pending}` : pending;
153
- if (visibleWidth(candidate) <= currentWidth) {
154
- current = candidate;
155
- pending = "";
156
- continue;
157
- }
158
-
159
- if (current) {
160
- lines.push(current);
161
- current = "";
162
- currentWidth = continuationLineWidth();
163
- continue;
164
- }
165
-
166
- const wrappedWord = wrapTextWithAnsi(pending, currentWidth);
167
- if (wrappedWord.length === 0) {
168
- pending = "";
169
- continue;
170
- }
171
-
172
- if (wrappedWord.length === 1) {
173
- current = wrappedWord[0] ?? "";
174
- pending = "";
175
- continue;
176
- }
177
-
178
- lines.push(...wrappedWord.slice(0, -1));
179
- pending = wrappedWord[wrappedWord.length - 1] ?? "";
180
- currentWidth = continuationLineWidth();
181
- }
182
- }
183
-
184
- if (current) lines.push(current);
185
- return lines;
186
- }
187
-
188
- function renderCollapsed(theme: ThinkingThemeLike, width: number, steps: DerivedThinkingStep[], activeStepId?: string, isActive = false, nowMs = Date.now()): string[] {
189
- const step = pickCollapsedStep(steps, activeStepId);
190
- if (!step) return [];
191
-
192
- const label = "Thinking";
193
- const icon = theme.fg(roleColor(step.role), step.icon);
194
- const activity = isActive ? pulseGlyph(theme, nowMs) : theme.fg("dim", "·");
195
- const activitySuffix = ` ${activity}`;
196
- const activityWidth = visibleWidth(activitySuffix);
197
- const prefix = `${theme.fg("muted", "│")} ${theme.fg("dim", label)} ${icon} `;
198
- const continuationPrefix = `${theme.fg("muted", "│")} ${" ".repeat(visibleWidth(`${label} ${step.icon} `))}`;
199
- const summaryLines = wrapCollapsedSummaryText(
200
- theme,
201
- step.summary,
202
- Math.max(1, width - visibleWidth(prefix) - activityWidth),
203
- Math.max(1, width - visibleWidth(continuationPrefix) - activityWidth),
204
- );
205
-
206
- if (summaryLines.length <= 1) {
207
- return [truncateToWidth(`${prefix}${summaryLines[0] ?? renderThinkingInlineMarkup(theme, step.summary)}${activitySuffix}`, width, "")];
208
- }
209
-
210
- return summaryLines.map((line, index) => {
211
- if (index === 0) return truncateToWidth(`${prefix}${line}`, width, "");
212
- if (index === summaryLines.length - 1) return truncateToWidth(`${continuationPrefix}${line}${activitySuffix}`, width, "");
213
- return truncateToWidth(`${continuationPrefix}${line}`, width, "");
214
- });
215
- }
216
-
217
- function stepHasEventType(step: DerivedThinkingStep, type: string): boolean {
218
- return step.summaryEvents?.some((event) => event.type === type) ?? false;
219
- }
220
-
221
- function selectSummarySteps(steps: DerivedThinkingStep[], activeStepId?: string): DerivedThinkingStep[] {
222
- if (steps.length <= 5) return steps;
223
-
224
- const indexed = steps.map((step, index) => ({ step, index }));
225
- const selected = new Set<number>();
226
- const activeIndex = activeStepId ? steps.findIndex((step) => step.id === activeStepId) : -1;
227
- let latestFailureIndex = -1;
228
- let latestSuccessAfterFailureIndex = -1;
229
-
230
- for (let index = 0; index < steps.length; index += 1) {
231
- const step = steps[index]!;
232
- if (step.hasExplicitFailure) {
233
- latestFailureIndex = index;
234
- latestSuccessAfterFailureIndex = -1;
235
- }
236
- if (latestFailureIndex !== -1 && step.hasExplicitSuccess && index > latestFailureIndex) {
237
- latestSuccessAfterFailureIndex = index;
238
- }
239
- }
240
-
241
- if (activeIndex !== -1) selected.add(activeIndex);
242
- if (latestFailureIndex !== -1) selected.add(latestFailureIndex);
243
- if (latestSuccessAfterFailureIndex !== -1) selected.add(latestSuccessAfterFailureIndex);
244
-
245
- const scoreEntry = ({ step, index }: { step: DerivedThinkingStep; index: number }): number => {
246
- let score = step.collapsedPriority ?? 0;
247
- const isStaleSuccessBeforeLatestFailure = step.hasExplicitSuccess && latestFailureIndex !== -1 && index < latestFailureIndex;
248
- if (index === latestFailureIndex && latestSuccessAfterFailureIndex === -1) score += 120;
249
- if (index === latestSuccessAfterFailureIndex) score += 110;
250
- if (stepHasEventType(step, "decision") || stepHasEventType(step, "plan_change")) score += 80;
251
- if (step.hasExplicitFailure) score += 50;
252
- if (step.hasExplicitSuccess && !isStaleSuccessBeforeLatestFailure) score += 45;
253
- if (isStaleSuccessBeforeLatestFailure) score -= 200;
254
- if (stepHasEventType(step, "focus") && !stepHasEventType(step, "decision") && !stepHasEventType(step, "plan_change") && !step.hasExplicitFailure && !step.hasExplicitSuccess) score -= 15;
255
- return score + (index / 100);
256
- };
257
-
258
- const targetCount = Math.min(5, steps.length);
259
- for (const entry of [...indexed].sort((left, right) => scoreEntry(right) - scoreEntry(left))) {
260
- if (selected.size >= targetCount) break;
261
- selected.add(entry.index);
262
- }
263
-
264
- return [...selected]
265
- .sort((left, right) => left - right)
266
- .map((index) => steps[index]!)
267
- .slice(0, targetCount);
268
- }
269
-
270
- function renderSummary(theme: ThinkingThemeLike, width: number, steps: DerivedThinkingStep[], activeStepId?: string): string[] {
271
- const lines = [
272
- truncateToWidth(`${theme.fg("muted", "┆")} ${theme.fg("dim", "Thinking UI · Summary")}`, width),
273
- ];
274
- const visibleSteps = selectSummarySteps(steps, activeStepId);
275
- for (let index = 0; index < visibleSteps.length; index++) {
276
- const step = visibleSteps[index]!;
277
- const connector = index === visibleSteps.length - 1 ? "└─" : "├─";
278
- lines.push(...wrapStepHeader(theme, width, step, step.id === activeStepId, connector));
279
- }
280
- return lines;
281
- }
282
-
283
- function renderThinkingInlineMarkup(theme: ThinkingThemeLike, text: string): string {
284
- const sanitized = sanitizeThinkingText(text);
285
- const segments = parseThinkingInlineSegments(sanitized);
286
- if (segments.length === 0) return theme.fg("thinkingText", sanitized);
287
- return segments.map((segment) => renderThinkingInlineSegment(theme, segment)).join("");
288
- }
289
-
290
- function renderThinkingDisplayLine(theme: ThinkingThemeLike, text: string): string {
291
- const headingMatch = text.match(/^(\s{0,3})#{1,6}\s+(.+)$/);
292
- if (headingMatch) {
293
- const indent = headingMatch[1] ?? "";
294
- const content = headingMatch[2] ?? "";
295
- return `${indent}${theme.bold(renderThinkingInlineMarkup(theme, content))}`;
296
- }
297
-
298
- const listMatch = text.match(/^(\s*)([-*+]|\d+[.)]|[a-z][.)])\s+(.+)$/i);
299
- if (listMatch) {
300
- const indent = listMatch[1] ?? "";
301
- const marker = listMatch[2] ?? "";
302
- const content = listMatch[3] ?? "";
303
- const renderedMarker = /^[-*+]$/.test(marker) ? "•" : marker;
304
- return `${indent}${theme.fg("muted", renderedMarker)} ${renderThinkingInlineMarkup(theme, content)}`;
305
- }
306
-
307
- return renderThinkingInlineMarkup(theme, text);
308
- }
309
-
310
- function renderWrappedRawText(theme: ThinkingThemeLike, text: string, width: number, prefix: string): string[] {
311
- const innerWidth = Math.max(8, width - visibleWidth(prefix));
312
- const sanitizedText = sanitizeThinkingText(text);
313
- const rawLines = sanitizedText.replace(/\t/g, " " ).split("\n");
314
- const rendered: string[] = [];
315
- for (const rawLine of rawLines) {
316
- if (rawLine.trim().length === 0) {
317
- rendered.push(truncateToWidth(prefix, width, ""));
318
- continue;
319
- }
320
- const styled = renderThinkingDisplayLine(theme, rawLine);
321
- const wrapped = wrapTextWithAnsi(styled, innerWidth);
322
- for (const line of wrapped) {
323
- rendered.push(truncateToWidth(`${prefix}${line}`, width, ""));
324
- }
325
- }
326
- return rendered;
327
- }
328
-
329
- function renderExpanded(theme: ThinkingThemeLike, width: number, steps: DerivedThinkingStep[], activeStepId?: string): string[] {
330
- const lines = [
331
- truncateToWidth(`${theme.fg("muted", "┆")} ${theme.fg("dim", "Thinking UI · Expanded")}`, width),
332
- ];
333
-
334
- for (let index = 0; index < steps.length; index++) {
335
- const step = steps[index]!;
336
- const connector = index === steps.length - 1 ? "└─" : "├─";
337
- const isActive = step.id === activeStepId;
338
- lines.push(...wrapStepHeader(theme, width, step, isActive, connector));
339
-
340
- const normalizedBody = step.body.trim();
341
- if (!normalizedBody) continue;
342
-
343
- const bodyPrefix = index === steps.length - 1 ? " " : `${theme.fg("muted", "│")} `;
344
- lines.push(...renderWrappedRawText(theme, normalizedBody, width, bodyPrefix));
345
- }
346
-
347
- return lines;
348
- }
349
-
350
- export function renderThinkingUILines(theme: ThinkingThemeLike, width: number, options: RenderOptions): string[] {
351
- if (options.steps.length === 0) return [];
352
- if (options.mode === "collapsed") {
353
- return renderCollapsed(theme, width, options.steps, options.activeStepId, options.isActive, options.nowMs);
354
- }
355
- if (options.mode === "expanded") {
356
- return renderExpanded(theme, width, options.steps, options.activeStepId);
357
- }
358
- return renderSummary(theme, width, options.steps, options.activeStepId);
359
- }
360
-
361
- // Throttle: during active streaming, skip re-derivation unless the total
362
- // character change across blocks exceeds this threshold. This keeps the
363
- // spinner and input responsive during deep thinking while still updating
364
- // the summary at a visible cadence.
365
- const MIN_DELTA_CHARS_FOR_REDERIVE = 200;
366
- const THROTTLE_CACHE_LIMIT = 4;
367
- const throttleCache = new Map<number, { lengths: number[]; steps: DerivedThinkingStep[] }>();
368
-
369
- // Step-core cache: thinking text is append-only, so completed step texts are
370
- // byte-stable across re-derivations. Caching the expensive core (summary +
371
- // role) by step text avoids re-summarizing N-1 unchanged steps on each
372
- // throttle-miss derive, linearizing the per-derive summarization cost.
373
- const STEP_CORE_CACHE_LIMIT = 500;
374
- const stepCoreCache = new Map<string, DerivedStepCore>();
375
-
376
- function cachingCoreResolver(stepText: string): DerivedStepCore {
377
- const cached = stepCoreCache.get(stepText);
378
- if (cached) {
379
- stepCoreCache.delete(stepText);
380
- stepCoreCache.set(stepText, cached);
381
- return cached;
382
- }
383
- const core = deriveStepCore(stepText);
384
- if (stepCoreCache.size >= STEP_CORE_CACHE_LIMIT) {
385
- const oldestKey = stepCoreCache.keys().next().value;
386
- if (oldestKey !== undefined) {
387
- stepCoreCache.delete(oldestKey);
388
- }
389
- }
390
- stepCoreCache.set(stepText, core);
391
- return core;
392
- }
393
-
394
- function totalLengthDelta(current: number[], previous: number[]): number {
395
- let delta = 0;
396
- for (let index = 0; index < Math.max(current.length, previous.length); index += 1) {
397
- delta += Math.abs((current[index] ?? 0) - (previous[index] ?? 0));
398
- }
399
- return delta;
400
- }
401
-
402
- function deriveOrReuseSteps(
403
- messageTimestamp: number,
404
- scopeKey: string,
405
- blocks: ThinkingSourceBlock[],
406
- ): DerivedThinkingStep[] {
407
- const active = getActiveThinkingState(messageTimestamp, scopeKey);
408
- const lengths = blocks.map((block) => block.text.length);
409
- const cached = throttleCache.get(messageTimestamp);
410
-
411
- // During active streaming, skip re-derivation when only a few chars were appended.
412
- if (active.active && cached) {
413
- const delta = totalLengthDelta(lengths, cached.lengths);
414
- if (delta < MIN_DELTA_CHARS_FOR_REDERIVE) {
415
- return cached.steps;
416
- }
417
- }
418
-
419
- const steps = deriveThinkingUI(blocks, cachingCoreResolver);
420
-
421
- if (active.active) {
422
- if (throttleCache.size >= THROTTLE_CACHE_LIMIT && !throttleCache.has(messageTimestamp)) {
423
- const oldestKey = throttleCache.keys().next().value;
424
- if (oldestKey !== undefined) {
425
- throttleCache.delete(oldestKey);
426
- }
427
- }
428
- throttleCache.set(messageTimestamp, { lengths, steps });
429
- }
430
-
431
- return steps;
432
- }
433
-
434
- export class ThinkingUIComponent implements Component {
435
- private readonly steps: DerivedThinkingStep[];
436
- private cacheKey?: string;
437
- private cachedLines?: string[];
438
- private readonly scopeKey: string;
439
- private readonly theme: ThinkingThemeLike;
440
- private readonly messageTimestamp: number;
441
-
442
- constructor(
443
- theme: ThinkingThemeLike,
444
- messageTimestamp: number,
445
- blocks: ThinkingSourceBlock[],
446
- scopeKey?: string,
447
- ) {
448
- this.theme = theme;
449
- this.messageTimestamp = messageTimestamp;
450
- this.scopeKey = scopeKey ?? getCurrentThinkingScopeKey();
451
- this.steps = deriveOrReuseSteps(messageTimestamp, this.scopeKey, blocks);
452
- }
453
-
454
- render(width: number): string[] {
455
- const mode = getThinkingUIMode(this.scopeKey);
456
- const active = getActiveThinkingState(this.messageTimestamp, this.scopeKey);
457
- const activeStepId = active.active && active.contentIndex !== undefined
458
- ? [...this.steps].reverse().find((step) => step.contentIndex === active.contentIndex)?.id
459
- : undefined;
460
- const shouldBypassCache = mode === "collapsed" && active.active;
461
- const nextCacheKey = `${width}:${mode}:${active.active ? 1 : 0}:${activeStepId ?? ""}`;
462
- if (!shouldBypassCache && this.cachedLines && this.cacheKey === nextCacheKey) {
463
- return this.cachedLines;
464
- }
465
-
466
- const lines = renderThinkingUILines(this.theme, width, {
467
- mode,
468
- steps: this.steps,
469
- activeStepId,
470
- isActive: active.active,
471
- nowMs: Date.now(),
472
- });
473
-
474
- if (!shouldBypassCache) {
475
- this.cacheKey = nextCacheKey;
476
- this.cachedLines = lines;
477
- } else {
478
- this.cacheKey = undefined;
479
- this.cachedLines = undefined;
480
- }
481
- return lines;
482
- }
483
-
484
- invalidate(): void {
485
- this.cacheKey = undefined;
486
- this.cachedLines = undefined;
487
- }
488
- }