pi-fluency 0.1.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.
@@ -0,0 +1,637 @@
1
+ import type {
2
+ ExtensionContext,
3
+ Theme,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ type Component,
7
+ Key,
8
+ matchesKey,
9
+ truncateToWidth,
10
+ visibleWidth,
11
+ type TUI,
12
+ wrapTextWithAnsi,
13
+ } from "@earendil-works/pi-tui";
14
+ import { computeFluencyAnalytics, type FluencyAnalytics, type RuleAnalytics } from "./analytics.js";
15
+ import { compactDiffFallback, renderCompactDiff } from "./diff.js";
16
+ import type { FluencyStore } from "./store.js";
17
+ import type { ReviewPattern } from "./types.js";
18
+ import {
19
+ ERRANT_CATEGORY_LABELS,
20
+ errantCategory,
21
+ type ErrantCategory,
22
+ } from "./taxonomy.js";
23
+
24
+ export type FluencyView = "inbox" | "accepted" | "ignored" | "stats";
25
+ type SelectionKeybinding =
26
+ | "tui.select.up"
27
+ | "tui.select.down"
28
+ | "tui.select.pageUp"
29
+ | "tui.select.pageDown"
30
+ | "tui.select.cancel";
31
+ export type IgnoreTarget = { kind: "pattern"; value: string } | { kind: "category"; value: ErrantCategory };
32
+ type MaybePromise = void | Promise<void>;
33
+
34
+ export interface FluencyOverlayOptions {
35
+ tui: Pick<TUI, "requestRender"> & { terminal?: { readonly rows: number } };
36
+ theme: Pick<Theme, "fg">;
37
+ keybindings: { matches(data: string, keybinding: SelectionKeybinding): boolean };
38
+ patterns(): ReviewPattern[];
39
+ stats(): FluencyAnalytics;
40
+ initialView?: FluencyView;
41
+ ignoredBy?(pattern: ReviewPattern): IgnoreTarget[];
42
+ selectIgnore?(title: string, options: string[]): Promise<string | undefined>;
43
+ accept(id: string): MaybePromise;
44
+ dismiss(id: string): MaybePromise;
45
+ ignorePattern(patternKey: string, pattern: ReviewPattern): MaybePromise;
46
+ ignoreCategory(category: ErrantCategory, pattern: ReviewPattern): MaybePromise;
47
+ restoreIgnored(targets: IgnoreTarget[], pattern: ReviewPattern): MaybePromise;
48
+ close(): void;
49
+ viewChanged?(view: FluencyView): void;
50
+ mutationError?(error: unknown): void;
51
+ }
52
+
53
+ const VIEWS: FluencyView[] = ["inbox", "accepted", "ignored", "stats"];
54
+ const FALLBACK_VERTICAL_BUDGET = 20;
55
+ const MIN_TERMINAL_ROWS = 15;
56
+ const HEADER_LINES = 2;
57
+ const FOOTER_LINES = 3;
58
+ const BORDER_LINES = 2;
59
+ const DETAIL_SCROLL_STEP = 5;
60
+
61
+ interface SourceSegment {
62
+ text: string;
63
+ start: number;
64
+ end: number;
65
+ }
66
+
67
+ function sourceSegments(source: string, width: number): SourceSegment[] {
68
+ let cursor = 0;
69
+ return wrapTextWithAnsi(source, width).map((text) => {
70
+ const start = source.indexOf(text, cursor);
71
+ const safeStart = start < 0 ? cursor : start;
72
+ cursor = safeStart + text.length;
73
+ return { text, start: safeStart, end: cursor };
74
+ });
75
+ }
76
+
77
+ function stringOffsetAtVisibleWidth(text: string, targetWidth: number): number {
78
+ let offset = 0;
79
+ for (const character of text) {
80
+ if (visibleWidth(text.slice(0, offset)) >= targetWidth) break;
81
+ offset += character.length;
82
+ }
83
+ return offset;
84
+ }
85
+
86
+ /** Wrap compact diff output while keeping a replacement annotation beside its source segment. */
87
+ export function wrapCompactDiff(lines: string[], marker: string, width: number): string[] {
88
+ const linePrefixWidth = 3;
89
+ const contentWidth = Math.max(1, width - linePrefixWidth);
90
+ const wrapped: string[] = [];
91
+ const append = (text: string): void => {
92
+ wrapped.push(`${wrapped.length === 0 ? ` ${marker} ` : " "}${text}`);
93
+ };
94
+
95
+ const fallback = compactDiffFallback(lines);
96
+ if (fallback) {
97
+ const arrow = "└─ ";
98
+ const sharedWidth = Math.max(1, contentWidth - visibleWidth(arrow));
99
+ const sourceLines = wrapTextWithAnsi(fallback.source, sharedWidth);
100
+ const correctionLines = wrapTextWithAnsi(fallback.correction, sharedWidth);
101
+ for (const line of sourceLines.length > 0 ? sourceLines : [""]) append(line);
102
+ correctionLines.forEach((line, index) => append(`${index === 0 ? arrow : " ".repeat(visibleWidth(arrow))}${line}`));
103
+ if (correctionLines.length === 0) append(arrow);
104
+ return wrapped;
105
+ }
106
+
107
+ const annotation = lines.length === 2 ? lines[1]!.match(/^( *)(└─ )(.*)$/u) : null;
108
+ if (annotation) {
109
+ const source = lines[0]!;
110
+ const arrow = annotation[2]!;
111
+ // Leave enough room for the arrow and one correction column instead of
112
+ // shifting the arrow left when the affected text is at a wrap boundary.
113
+ const sourceWidth = Math.max(1, contentWidth - visibleWidth(arrow));
114
+ const segments = sourceSegments(source, sourceWidth);
115
+ const sourceOffset = stringOffsetAtVisibleWidth(source, visibleWidth(annotation[1]!));
116
+ const affectedIndex = segments.findIndex((segment) => sourceOffset >= segment.start && sourceOffset < segment.end);
117
+ if (affectedIndex >= 0) {
118
+ for (const [index, segment] of segments.entries()) {
119
+ append(segment.text);
120
+ if (index !== affectedIndex) continue;
121
+
122
+ const indentWidth = visibleWidth(source.slice(segment.start, sourceOffset));
123
+ const correctionPrefix = `${" ".repeat(indentWidth)}${arrow}`;
124
+ const correctionWidth = Math.max(1, contentWidth - visibleWidth(correctionPrefix));
125
+ const correctionLines = wrapTextWithAnsi(annotation[3]!, correctionWidth);
126
+ if (correctionLines.length === 0) append(correctionPrefix);
127
+ else correctionLines.forEach((line, correctionIndex) => {
128
+ append(`${correctionIndex === 0 ? correctionPrefix : " ".repeat(visibleWidth(correctionPrefix))}${line}`);
129
+ });
130
+ }
131
+ return wrapped;
132
+ }
133
+ }
134
+
135
+ for (const rawLine of lines) {
136
+ const segments = wrapTextWithAnsi(rawLine, contentWidth);
137
+ for (const segment of segments.length > 0 ? segments : [""]) append(segment);
138
+ }
139
+ return wrapped;
140
+ }
141
+
142
+ function sanitizedError(error: unknown): string {
143
+ const message = error instanceof Error ? error.message : String(error);
144
+ return message.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?").trim() || "Unknown error";
145
+ }
146
+
147
+ /** Disposable keyboard-first inbox used inside Pi custom overlay lifecycle. */
148
+ export class FluencyOverlay implements Component {
149
+ private selectedIndex = 0;
150
+ private detailOffset = 0;
151
+ private maxDetailOffset = 0;
152
+ private view: FluencyView = "inbox";
153
+ private disposed = false;
154
+ private loadError: string | undefined;
155
+ private actionError: string | undefined;
156
+ private callbacks: FluencyOverlayOptions | undefined;
157
+
158
+ constructor(options: FluencyOverlayOptions) {
159
+ this.callbacks = options;
160
+ this.view = options.initialView ?? "inbox";
161
+ }
162
+
163
+ invalidate(): void {
164
+ // Stateless rendering: theme styles are rebuilt on every render.
165
+ }
166
+
167
+ dispose(): void {
168
+ this.disposed = true;
169
+ this.callbacks = undefined;
170
+ }
171
+
172
+ private ignoredBy(pattern: ReviewPattern): IgnoreTarget[] {
173
+ return this.callbacks?.ignoredBy?.(pattern) ?? [];
174
+ }
175
+
176
+ private getPatterns(): ReviewPattern[] {
177
+ if (!this.callbacks) return [];
178
+ try {
179
+ const patterns = this.callbacks.patterns();
180
+ this.loadError = undefined;
181
+ return patterns;
182
+ } catch (error) {
183
+ this.loadError = sanitizedError(error);
184
+ return [];
185
+ }
186
+ }
187
+
188
+ private items(): ReviewPattern[] {
189
+ if (this.view === "stats") return [];
190
+ const items = this.getPatterns().filter((pattern) => {
191
+ const ignored = this.ignoredBy(pattern).length > 0;
192
+ if (this.view === "ignored") return ignored;
193
+ if (ignored) return false;
194
+ if (this.view === "accepted") return pattern.acceptedCount > 0;
195
+ return pattern.pendingCount > 0;
196
+ });
197
+ this.selectedIndex = Math.max(0, Math.min(this.selectedIndex, Math.max(0, items.length - 1)));
198
+ return items;
199
+ }
200
+
201
+ private changed(): void {
202
+ if (this.disposed) return;
203
+ this.callbacks?.tui.requestRender();
204
+ }
205
+
206
+ private clearActionError(): void {
207
+ this.actionError = undefined;
208
+ }
209
+
210
+ private resetDetailPaging(): void {
211
+ this.detailOffset = 0;
212
+ this.maxDetailOffset = 0;
213
+ }
214
+
215
+ private clampSelectionAfterFilter(): void {
216
+ this.items();
217
+ }
218
+
219
+ private async perform(action: () => MaybePromise, onSuccess: () => void): Promise<void> {
220
+ this.clearActionError();
221
+ this.changed();
222
+ try {
223
+ await action();
224
+ if (this.disposed) return;
225
+ onSuccess();
226
+ this.actionError = undefined;
227
+ } catch (error) {
228
+ if (this.disposed) return;
229
+ this.actionError = sanitizedError(error);
230
+ try {
231
+ this.callbacks?.mutationError?.(error);
232
+ } catch {
233
+ // Status integration is advisory; keep the card-local failure usable.
234
+ }
235
+ }
236
+ this.changed();
237
+ }
238
+
239
+ async handleInput(data: string): Promise<void> {
240
+ const callbacks = this.callbacks;
241
+ if (this.disposed || !callbacks) return;
242
+ const items = this.items();
243
+
244
+ if (callbacks.keybindings.matches(data, "tui.select.cancel")) {
245
+ callbacks.close();
246
+ return;
247
+ }
248
+ if (data === "\t" || matchesKey(data, Key.tab)) {
249
+ this.clearActionError();
250
+ this.view = VIEWS[(VIEWS.indexOf(this.view) + 1) % VIEWS.length]!;
251
+ this.selectedIndex = 0;
252
+ this.resetDetailPaging();
253
+ callbacks.viewChanged?.(this.view);
254
+ this.changed();
255
+ return;
256
+ }
257
+ if (this.view !== "stats" && matchesKey(data, Key.left)) {
258
+ this.clearActionError();
259
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
260
+ this.resetDetailPaging();
261
+ this.changed();
262
+ return;
263
+ }
264
+ if (this.view !== "stats" && matchesKey(data, Key.right)) {
265
+ this.clearActionError();
266
+ this.selectedIndex = Math.min(Math.max(0, items.length - 1), this.selectedIndex + 1);
267
+ this.resetDetailPaging();
268
+ this.changed();
269
+ return;
270
+ }
271
+ if (callbacks.keybindings.matches(data, "tui.select.up") || data === "k") {
272
+ this.clearActionError();
273
+ this.detailOffset = Math.max(0, Math.min(this.detailOffset, this.maxDetailOffset) - 1);
274
+ this.changed();
275
+ return;
276
+ }
277
+ if (callbacks.keybindings.matches(data, "tui.select.down") || data === "j") {
278
+ this.clearActionError();
279
+ this.detailOffset = Math.min(this.maxDetailOffset, this.detailOffset + 1);
280
+ this.changed();
281
+ return;
282
+ }
283
+ if (callbacks.keybindings.matches(data, "tui.select.pageUp")) {
284
+ this.detailOffset = Math.max(0, Math.min(this.detailOffset, this.maxDetailOffset) - DETAIL_SCROLL_STEP);
285
+ this.changed();
286
+ return;
287
+ }
288
+ if (callbacks.keybindings.matches(data, "tui.select.pageDown")) {
289
+ this.detailOffset = Math.min(this.maxDetailOffset, this.detailOffset + DETAIL_SCROLL_STEP);
290
+ this.changed();
291
+ return;
292
+ }
293
+
294
+ const selected = items[this.selectedIndex];
295
+ if (!selected) return;
296
+ if ((data === "a" || data === "l") && this.view === "inbox") {
297
+ await this.perform(() => callbacks.accept(selected.id), () => {
298
+ this.resetDetailPaging();
299
+ this.clampSelectionAfterFilter();
300
+ });
301
+ return;
302
+ }
303
+ if (data === "d" && this.view === "inbox") {
304
+ await this.perform(() => callbacks.dismiss(selected.id), () => {
305
+ this.resetDetailPaging();
306
+ this.clampSelectionAfterFilter();
307
+ });
308
+ return;
309
+ }
310
+ if (data === "i" && this.view !== "ignored") {
311
+ await this.ignore(selected);
312
+ return;
313
+ }
314
+ if (data === "u" && this.view === "ignored") await this.unignore(selected);
315
+ }
316
+
317
+ private async ignore(pattern: ReviewPattern): Promise<void> {
318
+ const callbacks = this.callbacks;
319
+ if (!callbacks) return;
320
+ this.clearActionError();
321
+ this.changed();
322
+ const category = errantCategory(pattern.errorType);
323
+ const exact = `Ignore only: ${pattern.explanation}`;
324
+ const categoryOption = `Ignore this kind of mistake: ${ERRANT_CATEGORY_LABELS[category]}`;
325
+ const options = [exact, categoryOption, "Cancel"];
326
+ let selected: string | undefined;
327
+ try {
328
+ selected = callbacks.selectIgnore ? await callbacks.selectIgnore("Ignore fluency pattern", options) : exact;
329
+ } catch (error) {
330
+ if (!this.disposed) {
331
+ this.actionError = sanitizedError(error);
332
+ this.changed();
333
+ }
334
+ return;
335
+ }
336
+ if (this.disposed || !selected || selected === "Cancel") return;
337
+ const rerenderFromAuthoritativeState = () => {
338
+ this.resetDetailPaging();
339
+ this.clampSelectionAfterFilter();
340
+ };
341
+ if (selected === exact) {
342
+ await this.perform(
343
+ () => callbacks.ignorePattern(pattern.patternKey, pattern),
344
+ rerenderFromAuthoritativeState,
345
+ );
346
+ return;
347
+ }
348
+ if (selected !== categoryOption) return;
349
+ await this.perform(
350
+ () => callbacks.ignoreCategory(category, pattern),
351
+ rerenderFromAuthoritativeState,
352
+ );
353
+ }
354
+
355
+ private async unignore(pattern: ReviewPattern): Promise<void> {
356
+ const callbacks = this.callbacks;
357
+ if (!callbacks) return;
358
+ const seen = new Set<string>();
359
+ const targets = this.ignoredBy(pattern).filter((target) => {
360
+ const key = `${target.kind}:${target.value}`;
361
+ if (seen.has(key)) return false;
362
+ seen.add(key);
363
+ return true;
364
+ });
365
+ if (targets.length === 0) return;
366
+
367
+ await this.perform(
368
+ () => callbacks.restoreIgnored(targets, pattern),
369
+ () => {
370
+ this.resetDetailPaging();
371
+ this.clampSelectionAfterFilter();
372
+ },
373
+ );
374
+ }
375
+
376
+ private verticalBudget(): number {
377
+ const rows = this.callbacks?.tui.terminal?.rows;
378
+ if (typeof rows !== "number" || !Number.isFinite(rows) || rows <= 0) return FALLBACK_VERTICAL_BUDGET;
379
+ return Math.max(HEADER_LINES + FOOTER_LINES + BORDER_LINES + 1, Math.min(Math.floor(rows * 0.8), rows - 2));
380
+ }
381
+
382
+ private visiblePatternLines(pattern: ReviewPattern, width: number, available: number): string[] {
383
+ if (available <= 0) {
384
+ this.resetDetailPaging();
385
+ return [];
386
+ }
387
+ const diff = renderCompactDiff(pattern, {
388
+ deletion: (text) => this.callbacks?.theme.fg("error", `\u001b[9m${text}\u001b[29m`) ?? text,
389
+ insertion: (text) => this.callbacks?.theme.fg("success", `\u001b[4m${text}\u001b[24m`) ?? text,
390
+ });
391
+ const body = [...wrapCompactDiff(diff, "›", width), ""];
392
+ for (const line of wrapTextWithAnsi(pattern.explanation, Math.max(1, width - 2))) body.push(` ${line}`);
393
+ this.maxDetailOffset = Math.max(0, body.length - available);
394
+ this.detailOffset = Math.min(this.detailOffset, this.maxDetailOffset);
395
+ return body.slice(this.detailOffset, this.detailOffset + available);
396
+ }
397
+
398
+ private getStats(): FluencyAnalytics | undefined {
399
+ try {
400
+ const stats = this.callbacks?.stats();
401
+ this.loadError = undefined;
402
+ return stats;
403
+ } catch (error) {
404
+ this.loadError = sanitizedError(error);
405
+ return undefined;
406
+ }
407
+ }
408
+
409
+ private ruleTrend(rule: RuleAnalytics): string {
410
+ if (rule.trend === "new") return "✦ new";
411
+ if (rule.trend === "stable") return "→";
412
+ const arrow = rule.trend === "improving" ? "↓" : "↑";
413
+ const change = rule.changePercent === undefined ? "" : `${Math.abs(Math.round(rule.changePercent))}%`;
414
+ return `${arrow}${change}`;
415
+ }
416
+
417
+ private statsLines(stats: FluencyAnalytics, width: number): string[] {
418
+ const body: string[] = [];
419
+ const append = (text = ""): void => {
420
+ const wrapped = wrapTextWithAnsi(text, Math.max(1, width - 1));
421
+ if (wrapped.length === 0) body.push("");
422
+ else for (const line of wrapped) body.push(` ${line}`);
423
+ };
424
+ const periodRate = stats.periodRatePerThousand === undefined
425
+ ? "—"
426
+ : stats.periodRatePerThousand.toFixed(1);
427
+ const currentRate = stats.currentRatePerThousand === undefined
428
+ ? "—"
429
+ : stats.currentRatePerThousand.toFixed(1);
430
+ const coverage = stats.reviewCoverage === undefined
431
+ ? "—"
432
+ : `${Math.round(stats.reviewCoverage * 100)}%`;
433
+
434
+ append("Fluency trend · 30 days");
435
+ append();
436
+ append(`Accepted rate ${periodRate} / 1000 English words`);
437
+ append(`English words ${stats.englishWords.toLocaleString("en-US")}`);
438
+ append(`Accepted ${stats.accepted.toLocaleString("en-US")}`);
439
+ append(`One-off accepted mistakes ${stats.oneOffAccepted.toLocaleString("en-US")}`);
440
+ append(`Dismissed ${stats.dismissed.toLocaleString("en-US")}`);
441
+ append(`Pending ${stats.periodPendingOccurrences.toLocaleString("en-US")}`);
442
+ append(`Review coverage ${coverage}`);
443
+ append(`Active rules ${stats.activeRules.toLocaleString("en-US")}`);
444
+ append(`${stats.toolbarSparkline} ${currentRate === "—" ? "—/k" : `${currentRate}/k`}`);
445
+ append();
446
+ append("Concrete rules");
447
+ append(`↓ ${stats.trendCounts.improving} improving ↑ ${stats.trendCounts.worsening} worsening → ${stats.trendCounts.stable} stable ✦ ${stats.trendCounts.new} new`);
448
+ append();
449
+ if (stats.rules.length === 0) {
450
+ append("No recurring concrete rules in this period.");
451
+ } else {
452
+ for (const rule of stats.rules) {
453
+ append(rule.explanation);
454
+ const ruleRate = rule.ratePerThousand === undefined ? "—/k" : `${rule.ratePerThousand.toFixed(1)}/k`;
455
+ append(`${ruleRate} ${this.ruleTrend(rule)} ${rule.sparkline}`);
456
+ append();
457
+ }
458
+ }
459
+ return body;
460
+ }
461
+
462
+ private visibleStatsLines(stats: FluencyAnalytics, width: number, available: number): string[] {
463
+ if (available <= 0) {
464
+ this.resetDetailPaging();
465
+ return [];
466
+ }
467
+ const body = this.statsLines(stats, width);
468
+ this.maxDetailOffset = Math.max(0, body.length - available);
469
+ this.detailOffset = Math.min(this.detailOffset, this.maxDetailOffset);
470
+ return body.slice(this.detailOffset, this.detailOffset + available);
471
+ }
472
+
473
+ render(width: number): string[] {
474
+ if (this.disposed) return [];
475
+ const safeWidth = Math.max(0, width);
476
+ if (safeWidth < 2) return safeWidth === 1 ? [this.callbacks?.theme.fg("border", "│") ?? "│"] : [];
477
+ const contentWidth = safeWidth - 2;
478
+ const budget = this.verticalBudget();
479
+ const innerBudget = Math.max(0, budget - BORDER_LINES);
480
+ const items = this.items();
481
+ const stats = this.view === "stats" ? this.getStats() : undefined;
482
+ const title = ` Pi Fluency · ${this.view[0]!.toUpperCase()}${this.view.slice(1)}`;
483
+ const noun = this.view === "inbox" ? "pending" : this.view;
484
+ const selected = items[this.selectedIndex];
485
+ const paging = this.view === "stats"
486
+ ? "30 days"
487
+ : selected
488
+ ? `Pending ${selected.pendingCount} · accepted ${selected.acceptedCount} · ← ${this.selectedIndex + 1} / ${items.length} →`
489
+ : `0 ${noun}`;
490
+ const combinedHeaderWidth = visibleWidth(title) + 1 + visibleWidth(paging);
491
+ const headerLines = combinedHeaderWidth <= contentWidth
492
+ ? [title + " ".repeat(contentWidth - visibleWidth(title) - visibleWidth(paging)) + paging]
493
+ : [title, ...wrapTextWithAnsi(` ${paging}`, contentWidth)];
494
+ const lines: string[] = [...headerLines, ` ${"─".repeat(Math.max(0, contentWidth - 2))}`];
495
+
496
+ if (this.loadError) {
497
+ this.resetDetailPaging();
498
+ lines.push(` Could not load ${this.view === "stats" ? "statistics" : "patterns"}: ${this.loadError}`);
499
+ } else if (this.view === "stats" && stats) {
500
+ const reserved = headerLines.length + 1 + FOOTER_LINES;
501
+ lines.push(...this.visibleStatsLines(stats, contentWidth, Math.max(1, innerBudget - reserved)));
502
+ } else {
503
+ if (this.actionError) lines.push(` Action failed: ${this.actionError}`);
504
+ if (items.length === 0) {
505
+ this.resetDetailPaging();
506
+ lines.push(this.view === "inbox" ? " No pending patterns." : ` No ${this.view} patterns.`);
507
+ } else {
508
+ const reserved = headerLines.length + 1 + FOOTER_LINES + (this.actionError ? 1 : 0);
509
+ lines.push(...this.visiblePatternLines(selected!, contentWidth, Math.max(1, innerBudget - reserved)));
510
+ }
511
+ }
512
+
513
+ lines.push(` ${"─".repeat(Math.max(0, contentWidth - 2))}`);
514
+ if (this.view === "stats") {
515
+ lines.push(" ↑↓/jk scroll pgup/pgdn");
516
+ lines.push(" tab view esc close");
517
+ } else {
518
+ if (this.view === "inbox") lines.push(" ←→ card ↑↓/jk scroll a accept d dismiss");
519
+ else lines.push(" ←→ card ↑↓/jk scroll");
520
+ lines.push(this.view === "ignored" ? " u restore all tab view esc close" : " i ignore tab view esc close");
521
+ }
522
+
523
+ const border = (text: string): string => this.callbacks?.theme.fg("border", text) ?? text;
524
+ const top = border(`╭${"─".repeat(contentWidth)}╮`);
525
+ const bottom = border(`╰${"─".repeat(contentWidth)}╯`);
526
+ const framed = lines.slice(0, innerBudget).map((line) => {
527
+ const content = truncateToWidth(line, contentWidth, "");
528
+ const padding = " ".repeat(Math.max(0, contentWidth - visibleWidth(content)));
529
+ return `${border("│")}${content}${padding}${border("│")}`;
530
+ });
531
+ return [top, ...framed, bottom];
532
+ }
533
+ }
534
+
535
+ export async function showFluencyOverlay(
536
+ ctx: ExtensionContext,
537
+ store: FluencyStore,
538
+ signal?: AbortSignal,
539
+ onProgressChanged?: () => void,
540
+ onMutationError?: (error: unknown) => void,
541
+ initialView: FluencyView = "inbox",
542
+ now: () => number = Date.now,
543
+ ): Promise<void> {
544
+ if (ctx.mode !== "tui") {
545
+ ctx.ui.notify("Pi Fluency inbox requires interactive TUI mode", "warning");
546
+ return;
547
+ }
548
+ let overlay: FluencyOverlay | undefined;
549
+ let close: (() => void) | undefined;
550
+ const abort = (): void => {
551
+ overlay?.dispose();
552
+ close?.();
553
+ };
554
+ signal?.addEventListener("abort", abort, { once: true });
555
+ try {
556
+ await ctx.ui.custom<void>(
557
+ (tui, theme, keybindings, done) => {
558
+ close = done;
559
+ overlay = new FluencyOverlay({
560
+ tui,
561
+ theme,
562
+ keybindings,
563
+ patterns: () => store.listReviewPatterns(),
564
+ stats: () => {
565
+ const snapshot = store.getAnalyticsSnapshot();
566
+ return computeFluencyAnalytics({
567
+ observations: snapshot.observations,
568
+ occurrences: snapshot.occurrences,
569
+ patterns: snapshot.patterns,
570
+ ignoredPatternKeys: new Set(snapshot.ignoredPatternKeys),
571
+ ignoredCategories: new Set(snapshot.ignoredCategories),
572
+ now: now(),
573
+ });
574
+ },
575
+ initialView,
576
+ ignoredBy: (pattern) => {
577
+ const settings = store.getSettings();
578
+ const targets: IgnoreTarget[] = [];
579
+ if (settings.ignoredPatternKeys.includes(pattern.patternKey)) {
580
+ targets.push({ kind: "pattern", value: pattern.patternKey });
581
+ }
582
+ const category = errantCategory(pattern.errorType);
583
+ if (settings.ignoredCategories.includes(category)) {
584
+ targets.push({ kind: "category", value: category });
585
+ }
586
+ return targets;
587
+ },
588
+ selectIgnore: (title, options) => ctx.ui.select(title, options),
589
+ accept: async (id) => {
590
+ await store.acceptPattern(id);
591
+ onProgressChanged?.();
592
+ },
593
+ dismiss: async (id) => {
594
+ await store.dismissPattern(id);
595
+ onProgressChanged?.();
596
+ },
597
+ ignorePattern: async (patternKey) => {
598
+ await store.ignorePatternKey(patternKey);
599
+ onProgressChanged?.();
600
+ },
601
+ ignoreCategory: async (category) => {
602
+ await store.ignoreCategory(category);
603
+ onProgressChanged?.();
604
+ },
605
+ restoreIgnored: async (targets) => {
606
+ await store.restoreIgnoreTargets({
607
+ patternKeys: targets.filter((target) => target.kind === "pattern").map((target) => target.value),
608
+ categories: targets.filter((target): target is Extract<IgnoreTarget, { kind: "category" }> => target.kind === "category")
609
+ .map((target) => target.value),
610
+ });
611
+ onProgressChanged?.();
612
+ },
613
+ ...(onMutationError ? { mutationError: onMutationError } : {}),
614
+ close: () => done(),
615
+ });
616
+ if (signal?.aborted) abort();
617
+ return overlay;
618
+ },
619
+ {
620
+ overlay: true,
621
+ overlayOptions: {
622
+ anchor: "center",
623
+ width: "75%",
624
+ minWidth: 56,
625
+ maxHeight: "80%",
626
+ margin: 1,
627
+ visible: (_width, height) => height >= MIN_TERMINAL_ROWS,
628
+ },
629
+ },
630
+ );
631
+ } finally {
632
+ signal?.removeEventListener("abort", abort);
633
+ overlay?.dispose();
634
+ overlay = undefined;
635
+ close = undefined;
636
+ }
637
+ }
@@ -0,0 +1,48 @@
1
+ import { HISTORY_SCHEMA_VERSION, type FluencyEvent, type FluencyState } from "./types.js";
2
+ import { copyObservation, copyOccurrence, copyPattern, localDateKey } from "./state-reducer.js";
3
+
4
+ export type SnapshotHistoryEvent = Extract<FluencyEvent, { type: "snapshot" }>;
5
+
6
+ function shiftLocalDate(localDate: string, days: number): string {
7
+ const [year, month, day] = localDate.split("-").map(Number);
8
+ return new Date(Date.UTC(year!, month! - 1, day! + days)).toISOString().slice(0, 10);
9
+ }
10
+
11
+ export function buildRetainedSnapshot(
12
+ state: FluencyState,
13
+ options: { now: number; retentionLimit: number },
14
+ ): SnapshotHistoryEvent {
15
+ const hashLimit = Math.max(0, Math.floor(options.retentionLimit * 10));
16
+ const reviewedCutoff = shiftLocalDate(localDateKey(options.now), -364);
17
+ const occurrences = [...state.occurrences.values()]
18
+ .filter((occurrence) => occurrence.decision === "pending" || occurrence.localDate >= reviewedCutoff)
19
+ .map(copyOccurrence);
20
+ const retainedOccurrenceIds = new Set(occurrences.map((occurrence) => occurrence.id));
21
+ const observations = [...state.observations.values()]
22
+ .filter((observation) => observation.localDate >= reviewedCutoff
23
+ || observation.occurrenceIds.some((id) => retainedOccurrenceIds.has(id)))
24
+ .map((observation) => ({
25
+ ...copyObservation(observation),
26
+ occurrenceIds: observation.occurrenceIds.filter((id) => retainedOccurrenceIds.has(id)),
27
+ }));
28
+ const retainedPatternIds = new Set(occurrences.map((occurrence) => occurrence.patternId));
29
+ const patterns = [...state.patterns.values()]
30
+ .filter((pattern) => retainedPatternIds.has(pattern.id))
31
+ .sort((left, right) => right.lastSeenAt - left.lastSeenAt)
32
+ .map(copyPattern);
33
+ const recentHashes = hashLimit === 0 ? [] : [...state.processedPromptHashes].slice(-hashLimit);
34
+ const processedPromptHashes = [...new Set([
35
+ ...observations.map((observation) => observation.promptHash),
36
+ ...recentHashes,
37
+ ])];
38
+
39
+ return {
40
+ schemaVersion: HISTORY_SCHEMA_VERSION,
41
+ type: "snapshot",
42
+ at: options.now,
43
+ patterns,
44
+ observations,
45
+ occurrences,
46
+ processedPromptHashes,
47
+ };
48
+ }