pi-fluency 0.1.3 → 0.2.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/README.md +30 -5
- package/extensions/pi-fluency/analytics.ts +151 -0
- package/extensions/pi-fluency/analyzer.ts +29 -3
- package/extensions/pi-fluency/coaching-overlay.ts +259 -0
- package/extensions/pi-fluency/coaching.ts +158 -0
- package/extensions/pi-fluency/collector.ts +3 -2
- package/extensions/pi-fluency/index.ts +538 -27
- package/extensions/pi-fluency/overlay.ts +296 -40
- package/extensions/pi-fluency/practice-settings.ts +201 -0
- package/extensions/pi-fluency/setup.ts +1 -1
- package/extensions/pi-fluency/status.ts +3 -0
- package/extensions/pi-fluency/store.ts +294 -13
- package/extensions/pi-fluency/types.ts +50 -0
- package/extensions/pi-fluency/worker.ts +408 -27
- package/package.json +1 -1
|
@@ -11,10 +11,20 @@ import {
|
|
|
11
11
|
type TUI,
|
|
12
12
|
wrapTextWithAnsi,
|
|
13
13
|
} from "@earendil-works/pi-tui";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
computeFluencyAnalytics,
|
|
16
|
+
resolvePracticeTargets,
|
|
17
|
+
type FluencyAnalytics,
|
|
18
|
+
type RuleAnalytics,
|
|
19
|
+
} from "./analytics.js";
|
|
15
20
|
import { compactDiffFallback, renderCompactDiff } from "./diff.js";
|
|
16
21
|
import type { FluencyStore } from "./store.js";
|
|
17
|
-
import type {
|
|
22
|
+
import type {
|
|
23
|
+
PracticeSettings,
|
|
24
|
+
PracticeTarget,
|
|
25
|
+
ResolvedPracticeTarget,
|
|
26
|
+
ReviewPattern,
|
|
27
|
+
} from "./types.js";
|
|
18
28
|
import {
|
|
19
29
|
ERRANT_CATEGORY_LABELS,
|
|
20
30
|
errantCategory,
|
|
@@ -31,12 +41,20 @@ type SelectionKeybinding =
|
|
|
31
41
|
export type IgnoreTarget = { kind: "pattern"; value: string } | { kind: "category"; value: ErrantCategory };
|
|
32
42
|
type MaybePromise = void | Promise<void>;
|
|
33
43
|
|
|
44
|
+
export interface PracticeOverlayState {
|
|
45
|
+
settings: PracticeSettings;
|
|
46
|
+
targets: ResolvedPracticeTarget[];
|
|
47
|
+
sessionSnoozed: boolean;
|
|
48
|
+
now: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
34
51
|
export interface FluencyOverlayOptions {
|
|
35
52
|
tui: Pick<TUI, "requestRender"> & { terminal?: { readonly rows: number } };
|
|
36
53
|
theme: Pick<Theme, "fg">;
|
|
37
54
|
keybindings: { matches(data: string, keybinding: SelectionKeybinding): boolean };
|
|
38
55
|
patterns(): ReviewPattern[];
|
|
39
56
|
stats(): FluencyAnalytics;
|
|
57
|
+
practice?(): PracticeOverlayState;
|
|
40
58
|
initialView?: FluencyView;
|
|
41
59
|
ignoredBy?(pattern: ReviewPattern): IgnoreTarget[];
|
|
42
60
|
selectIgnore?(title: string, options: string[]): Promise<string | undefined>;
|
|
@@ -45,6 +63,8 @@ export interface FluencyOverlayOptions {
|
|
|
45
63
|
ignorePattern(patternKey: string, pattern: ReviewPattern): MaybePromise;
|
|
46
64
|
ignoreCategory(category: ErrantCategory, pattern: ReviewPattern): MaybePromise;
|
|
47
65
|
restoreIgnored(targets: IgnoreTarget[], pattern: ReviewPattern): MaybePromise;
|
|
66
|
+
activatePractice?(target?: PracticeTarget): MaybePromise;
|
|
67
|
+
setPracticeTarget?(target: PracticeTarget, selected: boolean): MaybePromise;
|
|
48
68
|
close(): void;
|
|
49
69
|
viewChanged?(view: FluencyView): void;
|
|
50
70
|
mutationError?(error: unknown): void;
|
|
@@ -57,6 +77,20 @@ const HEADER_LINES = 2;
|
|
|
57
77
|
const FOOTER_LINES = 3;
|
|
58
78
|
const BORDER_LINES = 2;
|
|
59
79
|
const DETAIL_SCROLL_STEP = 5;
|
|
80
|
+
const MAX_ERROR_LENGTH = 200;
|
|
81
|
+
|
|
82
|
+
interface StatsRuleRow {
|
|
83
|
+
rowKey: string;
|
|
84
|
+
target: PracticeTarget;
|
|
85
|
+
selected: boolean;
|
|
86
|
+
paused: boolean;
|
|
87
|
+
section: "recurring" | "historical" | "paused";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface StatsBody {
|
|
91
|
+
lines: string[];
|
|
92
|
+
focusedRange?: { start: number; end: number };
|
|
93
|
+
}
|
|
60
94
|
|
|
61
95
|
interface SourceSegment {
|
|
62
96
|
text: string;
|
|
@@ -141,7 +175,10 @@ export function wrapCompactDiff(lines: string[], marker: string, width: number):
|
|
|
141
175
|
|
|
142
176
|
function sanitizedError(error: unknown): string {
|
|
143
177
|
const message = error instanceof Error ? error.message : String(error);
|
|
144
|
-
|
|
178
|
+
const sanitized = message.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?").trim() || "Unknown error";
|
|
179
|
+
return sanitized.length <= MAX_ERROR_LENGTH
|
|
180
|
+
? sanitized
|
|
181
|
+
: `${sanitized.slice(0, MAX_ERROR_LENGTH - 1)}…`;
|
|
145
182
|
}
|
|
146
183
|
|
|
147
184
|
/** Disposable keyboard-first inbox used inside Pi custom overlay lifecycle. */
|
|
@@ -153,6 +190,9 @@ export class FluencyOverlay implements Component {
|
|
|
153
190
|
private disposed = false;
|
|
154
191
|
private loadError: string | undefined;
|
|
155
192
|
private actionError: string | undefined;
|
|
193
|
+
private statsRuleIndex = 0;
|
|
194
|
+
private statsRuleFocusKey: string | undefined;
|
|
195
|
+
private statsRulePending = false;
|
|
156
196
|
private callbacks: FluencyOverlayOptions | undefined;
|
|
157
197
|
|
|
158
198
|
constructor(options: FluencyOverlayOptions) {
|
|
@@ -236,15 +276,133 @@ export class FluencyOverlay implements Component {
|
|
|
236
276
|
this.changed();
|
|
237
277
|
}
|
|
238
278
|
|
|
279
|
+
private getPractice(): PracticeOverlayState | undefined {
|
|
280
|
+
try {
|
|
281
|
+
const practice = this.callbacks?.practice?.();
|
|
282
|
+
this.loadError = undefined;
|
|
283
|
+
return practice;
|
|
284
|
+
} catch (error) {
|
|
285
|
+
this.loadError = sanitizedError(error);
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private statsRuleRows(stats: FluencyAnalytics, practice: PracticeOverlayState): StatsRuleRow[] {
|
|
291
|
+
const selectedByExplanation = new Map(practice.targets.map((target) => [target.explanation, target]));
|
|
292
|
+
const recurring: StatsRuleRow[] = stats.rules.flatMap((rule) => {
|
|
293
|
+
const selected = selectedByExplanation.get(rule.explanation);
|
|
294
|
+
if (selected && !selected.coachingEnabled) return [];
|
|
295
|
+
return [{
|
|
296
|
+
rowKey: rule.rowKey,
|
|
297
|
+
target: { explanation: rule.explanation, memberPatternKeys: [...rule.memberPatternKeys] },
|
|
298
|
+
selected: selected !== undefined,
|
|
299
|
+
paused: false,
|
|
300
|
+
section: "recurring" as const,
|
|
301
|
+
}];
|
|
302
|
+
});
|
|
303
|
+
const recurringExplanations = new Set(stats.rules.map((rule) => rule.explanation));
|
|
304
|
+
const historical: StatsRuleRow[] = practice.targets
|
|
305
|
+
.filter((target) => target.coachingEnabled && !recurringExplanations.has(target.explanation))
|
|
306
|
+
.map((target) => ({
|
|
307
|
+
rowKey: target.rowKey,
|
|
308
|
+
target: { explanation: target.explanation, memberPatternKeys: [...target.memberPatternKeys] },
|
|
309
|
+
selected: true,
|
|
310
|
+
paused: false,
|
|
311
|
+
section: "historical" as const,
|
|
312
|
+
}));
|
|
313
|
+
const paused: StatsRuleRow[] = practice.targets
|
|
314
|
+
.filter((target) => !target.coachingEnabled)
|
|
315
|
+
.map((target) => ({
|
|
316
|
+
rowKey: target.rowKey,
|
|
317
|
+
target: { explanation: target.explanation, memberPatternKeys: [...target.memberPatternKeys] },
|
|
318
|
+
selected: true,
|
|
319
|
+
paused: true,
|
|
320
|
+
section: "paused" as const,
|
|
321
|
+
}));
|
|
322
|
+
const rows = [...recurring, ...historical, ...paused];
|
|
323
|
+
if (this.statsRuleFocusKey) {
|
|
324
|
+
const index = rows.findIndex((row) => row.rowKey === this.statsRuleFocusKey);
|
|
325
|
+
if (index >= 0) this.statsRuleIndex = index;
|
|
326
|
+
}
|
|
327
|
+
this.statsRuleIndex = Math.max(0, Math.min(this.statsRuleIndex, Math.max(0, rows.length - 1)));
|
|
328
|
+
this.statsRuleFocusKey = rows[this.statsRuleIndex]?.rowKey;
|
|
329
|
+
return rows;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private async performStatsRule(action: () => MaybePromise, focusKey: string): Promise<void> {
|
|
333
|
+
if (this.statsRulePending) return;
|
|
334
|
+
this.clearActionError();
|
|
335
|
+
this.statsRulePending = true;
|
|
336
|
+
this.changed();
|
|
337
|
+
try {
|
|
338
|
+
await action();
|
|
339
|
+
if (this.disposed) return;
|
|
340
|
+
this.actionError = undefined;
|
|
341
|
+
} catch (error) {
|
|
342
|
+
if (this.disposed) return;
|
|
343
|
+
this.actionError = sanitizedError(error);
|
|
344
|
+
this.statsRuleFocusKey = focusKey;
|
|
345
|
+
try { this.callbacks?.mutationError?.(error); } catch { /* advisory */ }
|
|
346
|
+
} finally {
|
|
347
|
+
if (!this.disposed) {
|
|
348
|
+
this.statsRulePending = false;
|
|
349
|
+
this.changed();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private moveStatsRuleFocus(direction: -1 | 1): void {
|
|
355
|
+
if (this.statsRulePending) return;
|
|
356
|
+
const stats = this.getStats();
|
|
357
|
+
const practice = this.getPractice();
|
|
358
|
+
if (!stats || !practice) return;
|
|
359
|
+
const rows = this.statsRuleRows(stats, practice);
|
|
360
|
+
this.statsRuleIndex = Math.max(0, Math.min(rows.length - 1, this.statsRuleIndex + direction));
|
|
361
|
+
this.statsRuleFocusKey = rows[this.statsRuleIndex]?.rowKey;
|
|
362
|
+
this.clearActionError();
|
|
363
|
+
this.changed();
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
private async toggleFocusedStatsRule(): Promise<void> {
|
|
367
|
+
if (this.statsRulePending) return;
|
|
368
|
+
const callbacks = this.callbacks;
|
|
369
|
+
const stats = this.getStats();
|
|
370
|
+
const practice = this.getPractice();
|
|
371
|
+
if (!callbacks || !stats || !practice) return;
|
|
372
|
+
const rows = this.statsRuleRows(stats, practice);
|
|
373
|
+
const row = rows[this.statsRuleIndex];
|
|
374
|
+
if (!row) return;
|
|
375
|
+
if (!row.selected && practice.settings.consentedAt === undefined) {
|
|
376
|
+
await this.performStatsRule(() => callbacks.activatePractice?.(row.target), row.rowKey);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
await this.performStatsRule(() => callbacks.setPracticeTarget?.(row.target, !row.selected), row.rowKey);
|
|
380
|
+
}
|
|
381
|
+
|
|
239
382
|
async handleInput(data: string): Promise<void> {
|
|
240
383
|
const callbacks = this.callbacks;
|
|
241
384
|
if (this.disposed || !callbacks) return;
|
|
385
|
+
if (this.statsRulePending) return;
|
|
242
386
|
const items = this.items();
|
|
243
387
|
|
|
244
388
|
if (callbacks.keybindings.matches(data, "tui.select.cancel")) {
|
|
245
389
|
callbacks.close();
|
|
246
390
|
return;
|
|
247
391
|
}
|
|
392
|
+
if (this.view === "stats") {
|
|
393
|
+
if (callbacks.keybindings.matches(data, "tui.select.up") || data === "k") {
|
|
394
|
+
this.moveStatsRuleFocus(-1);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (callbacks.keybindings.matches(data, "tui.select.down") || data === "j") {
|
|
398
|
+
this.moveStatsRuleFocus(1);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (data === " ") {
|
|
402
|
+
await this.toggleFocusedStatsRule();
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
248
406
|
if (data === "\t" || matchesKey(data, Key.tab)) {
|
|
249
407
|
this.clearActionError();
|
|
250
408
|
this.view = VIEWS[(VIEWS.indexOf(this.view) + 1) % VIEWS.length]!;
|
|
@@ -376,7 +534,7 @@ export class FluencyOverlay implements Component {
|
|
|
376
534
|
private verticalBudget(): number {
|
|
377
535
|
const rows = this.callbacks?.tui.terminal?.rows;
|
|
378
536
|
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));
|
|
537
|
+
return Math.max(HEADER_LINES + FOOTER_LINES + BORDER_LINES + 1, Math.min(Math.floor(rows * 0.8) + 1, rows - 2));
|
|
380
538
|
}
|
|
381
539
|
|
|
382
540
|
private visiblePatternLines(pattern: ReviewPattern, width: number, available: number): string[] {
|
|
@@ -406,32 +564,41 @@ export class FluencyOverlay implements Component {
|
|
|
406
564
|
}
|
|
407
565
|
}
|
|
408
566
|
|
|
409
|
-
private ruleTrend(rule: RuleAnalytics): string {
|
|
410
|
-
if (rule.trend === "new") return
|
|
567
|
+
private ruleTrend(rule: RuleAnalytics): string | undefined {
|
|
568
|
+
if (rule.trend === "new") return undefined;
|
|
411
569
|
if (rule.trend === "stable") return "→";
|
|
412
570
|
const arrow = rule.trend === "improving" ? "↓" : "↑";
|
|
413
571
|
const change = rule.changePercent === undefined ? "" : `${Math.abs(Math.round(rule.changePercent))}%`;
|
|
414
572
|
return `${arrow}${change}`;
|
|
415
573
|
}
|
|
416
574
|
|
|
417
|
-
private
|
|
418
|
-
const
|
|
419
|
-
const append = (text = ""):
|
|
575
|
+
private statsBody(stats: FluencyAnalytics, width: number, practice?: PracticeOverlayState): StatsBody {
|
|
576
|
+
const lines: string[] = [];
|
|
577
|
+
const append = (text = ""): { start: number; end: number } => {
|
|
578
|
+
const start = lines.length;
|
|
420
579
|
const wrapped = wrapTextWithAnsi(text, Math.max(1, width - 1));
|
|
421
|
-
if (wrapped.length === 0)
|
|
422
|
-
else for (const line of wrapped)
|
|
580
|
+
if (wrapped.length === 0) lines.push("");
|
|
581
|
+
else for (const line of wrapped) lines.push(` ${line}`);
|
|
582
|
+
return { start, end: lines.length - 1 };
|
|
583
|
+
};
|
|
584
|
+
const appendHanging = (prefix: string, content: string): { start: number; end: number } => {
|
|
585
|
+
const available = Math.max(1, width - 1 - visibleWidth(prefix));
|
|
586
|
+
const wrapped = wrapTextWithAnsi(content, available);
|
|
587
|
+
const start = lines.length;
|
|
588
|
+
const continuation = " ".repeat(visibleWidth(prefix));
|
|
589
|
+
wrapped.forEach((line, index) => lines.push(` ${index === 0 ? prefix : continuation}${line}`));
|
|
590
|
+
return { start, end: lines.length - 1 };
|
|
423
591
|
};
|
|
424
592
|
const periodRate = stats.periodRatePerThousand === undefined
|
|
425
593
|
? "—"
|
|
426
594
|
: stats.periodRatePerThousand.toFixed(1);
|
|
427
|
-
const currentRate = stats.currentRatePerThousand === undefined
|
|
428
|
-
? "—"
|
|
429
|
-
: stats.currentRatePerThousand.toFixed(1);
|
|
430
595
|
const coverage = stats.reviewCoverage === undefined
|
|
431
596
|
? "—"
|
|
432
597
|
: `${Math.round(stats.reviewCoverage * 100)}%`;
|
|
433
598
|
|
|
434
|
-
append("
|
|
599
|
+
append("Mistakes / 1,000 words · last 30 days");
|
|
600
|
+
append(`${stats.dailyRateSparkline} ${periodRate}/k`);
|
|
601
|
+
append("30 days ago ... today");
|
|
435
602
|
append();
|
|
436
603
|
append(`Accepted rate ${periodRate} / 1000 English words`);
|
|
437
604
|
append(`English words ${stats.englishWords.toLocaleString("en-US")}`);
|
|
@@ -441,33 +608,103 @@ export class FluencyOverlay implements Component {
|
|
|
441
608
|
append(`Pending ${stats.periodPendingOccurrences.toLocaleString("en-US")}`);
|
|
442
609
|
append(`Review coverage ${coverage}`);
|
|
443
610
|
append(`Active rules ${stats.activeRules.toLocaleString("en-US")}`);
|
|
444
|
-
append(`${stats.toolbarSparkline} ${currentRate === "—" ? "—/k" : `${currentRate}/k`}`);
|
|
445
611
|
append();
|
|
446
612
|
append("Concrete rules");
|
|
447
|
-
append(`↓ ${stats.trendCounts.improving} improving ↑ ${stats.trendCounts.worsening} worsening → ${stats.trendCounts.stable} stable
|
|
613
|
+
append(`↓ ${stats.trendCounts.improving} improving ↑ ${stats.trendCounts.worsening} worsening → ${stats.trendCounts.stable} stable`);
|
|
448
614
|
append();
|
|
449
|
-
|
|
615
|
+
|
|
616
|
+
if (!practice) {
|
|
617
|
+
if (stats.rules.length === 0) append("No recurring concrete rules in this period.");
|
|
618
|
+
return { lines };
|
|
619
|
+
}
|
|
620
|
+
const rows = this.statsRuleRows(stats, practice);
|
|
621
|
+
if (rows.length === 0) {
|
|
450
622
|
append("No recurring concrete rules in this period.");
|
|
451
|
-
|
|
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
|
-
}
|
|
623
|
+
return { lines };
|
|
458
624
|
}
|
|
459
|
-
|
|
625
|
+
|
|
626
|
+
const labels: Partial<Record<StatsRuleRow["section"], string>> = {
|
|
627
|
+
historical: "Selected, not currently recurring",
|
|
628
|
+
paused: "Selected, paused by Ignore",
|
|
629
|
+
};
|
|
630
|
+
let section: StatsRuleRow["section"] | undefined;
|
|
631
|
+
let focusedRange: StatsBody["focusedRange"];
|
|
632
|
+
rows.forEach((row, index) => {
|
|
633
|
+
if (row.section !== section) {
|
|
634
|
+
if (section !== undefined) append();
|
|
635
|
+
section = row.section;
|
|
636
|
+
const label = labels[section];
|
|
637
|
+
if (label) append(label);
|
|
638
|
+
}
|
|
639
|
+
const marker = index === this.statsRuleIndex ? ">" : " ";
|
|
640
|
+
const prefix = `${marker} ${row.selected ? "[x]" : "[ ]"} `;
|
|
641
|
+
const suffix = row.paused ? " · paused by Ignore" : "";
|
|
642
|
+
const title = appendHanging(prefix, `${row.target.explanation}${suffix}`);
|
|
643
|
+
let end = title.end;
|
|
644
|
+
if (row.section === "recurring") {
|
|
645
|
+
const rule = stats.rules.find((candidate) => candidate.rowKey === row.rowKey);
|
|
646
|
+
if (rule) {
|
|
647
|
+
const ruleRate = rule.ratePerThousand === undefined ? "—/k" : `${rule.ratePerThousand.toFixed(1)}/k`;
|
|
648
|
+
const metadata = [ruleRate, this.ruleTrend(rule), rule.sparkline].filter((field): field is string => Boolean(field));
|
|
649
|
+
end = appendHanging(" ", metadata.join(" ")).end;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (this.actionError && index === this.statsRuleIndex) {
|
|
653
|
+
end = appendHanging(" ", `Action failed: ${this.actionError}`).end;
|
|
654
|
+
}
|
|
655
|
+
if (index === this.statsRuleIndex) focusedRange = { start: title.start, end };
|
|
656
|
+
});
|
|
657
|
+
return { lines, ...(focusedRange ? { focusedRange } : {}) };
|
|
460
658
|
}
|
|
461
659
|
|
|
462
|
-
private visibleStatsLines(stats: FluencyAnalytics, width: number, available: number): string[] {
|
|
660
|
+
private visibleStatsLines(stats: FluencyAnalytics, width: number, available: number, practice?: PracticeOverlayState): string[] {
|
|
463
661
|
if (available <= 0) {
|
|
464
662
|
this.resetDetailPaging();
|
|
465
663
|
return [];
|
|
466
664
|
}
|
|
467
|
-
const body = this.
|
|
468
|
-
this.maxDetailOffset = Math.max(0, body.length - available);
|
|
665
|
+
const body = this.statsBody(stats, width, practice);
|
|
666
|
+
this.maxDetailOffset = Math.max(0, body.lines.length - available);
|
|
469
667
|
this.detailOffset = Math.min(this.detailOffset, this.maxDetailOffset);
|
|
470
|
-
|
|
668
|
+
const focused = body.focusedRange;
|
|
669
|
+
if (focused) {
|
|
670
|
+
const focusHeight = focused.end - focused.start + 1;
|
|
671
|
+
if (focusHeight > available) {
|
|
672
|
+
this.detailOffset = focused.start;
|
|
673
|
+
} else if (focused.start < this.detailOffset) {
|
|
674
|
+
this.detailOffset = focused.start;
|
|
675
|
+
} else if (focused.end >= this.detailOffset + available) {
|
|
676
|
+
this.detailOffset = focused.end - available + 1;
|
|
677
|
+
}
|
|
678
|
+
this.detailOffset = Math.max(0, Math.min(this.detailOffset, this.maxDetailOffset));
|
|
679
|
+
}
|
|
680
|
+
return body.lines.slice(this.detailOffset, this.detailOffset + available);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
private practiceStatus(practice?: PracticeOverlayState): string | undefined {
|
|
684
|
+
if (!practice) return undefined;
|
|
685
|
+
if (!practice.settings.enabled) return " Practice off · selected rules not checked";
|
|
686
|
+
const globalSnoozed = (practice.settings.snoozedUntil ?? 0) > practice.now;
|
|
687
|
+
if (practice.sessionSnoozed && globalSnoozed) {
|
|
688
|
+
return " Session + global snooze · selected rules not checked";
|
|
689
|
+
}
|
|
690
|
+
if (practice.sessionSnoozed) return " Session snooze · selected rules not checked";
|
|
691
|
+
if (globalSnoozed) return " Global snooze · selected rules not checked";
|
|
692
|
+
return " Practice on · selected rules checked before send";
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
private footerLines(contentWidth: number, practice?: PracticeOverlayState): string[] {
|
|
696
|
+
if (this.view === "stats") {
|
|
697
|
+
const action = this.statsRulePending
|
|
698
|
+
? " Saving… focus and toggle disabled"
|
|
699
|
+
: " ↑↓/jk focus + scroll Space toggle tab view esc close";
|
|
700
|
+
const status = this.practiceStatus(practice);
|
|
701
|
+
return [
|
|
702
|
+
...(status ? wrapTextWithAnsi(status, Math.max(1, contentWidth)) : []),
|
|
703
|
+
...wrapTextWithAnsi(action, Math.max(1, contentWidth)),
|
|
704
|
+
];
|
|
705
|
+
}
|
|
706
|
+
if (this.view === "inbox") return [" ←→ card ↑↓/jk scroll a accept d dismiss", " i ignore tab view esc close"];
|
|
707
|
+
return [" ←→ card ↑↓/jk scroll", this.view === "ignored" ? " u restore all tab view esc close" : " i ignore tab view esc close"];
|
|
471
708
|
}
|
|
472
709
|
|
|
473
710
|
render(width: number): string[] {
|
|
@@ -479,6 +716,7 @@ export class FluencyOverlay implements Component {
|
|
|
479
716
|
const innerBudget = Math.max(0, budget - BORDER_LINES);
|
|
480
717
|
const items = this.items();
|
|
481
718
|
const stats = this.view === "stats" ? this.getStats() : undefined;
|
|
719
|
+
const practice = this.view === "stats" && stats ? this.getPractice() : undefined;
|
|
482
720
|
const title = ` Pi Fluency · ${this.view[0]!.toUpperCase()}${this.view.slice(1)}`;
|
|
483
721
|
const noun = this.view === "inbox" ? "pending" : this.view;
|
|
484
722
|
const selected = items[this.selectedIndex];
|
|
@@ -491,14 +729,15 @@ export class FluencyOverlay implements Component {
|
|
|
491
729
|
const headerLines = combinedHeaderWidth <= contentWidth
|
|
492
730
|
? [title + " ".repeat(contentWidth - visibleWidth(title) - visibleWidth(paging)) + paging]
|
|
493
731
|
: [title, ...wrapTextWithAnsi(` ${paging}`, contentWidth)];
|
|
732
|
+
const footerLines = this.footerLines(contentWidth, practice);
|
|
494
733
|
const lines: string[] = [...headerLines, ` ${"─".repeat(Math.max(0, contentWidth - 2))}`];
|
|
495
734
|
|
|
496
735
|
if (this.loadError) {
|
|
497
736
|
this.resetDetailPaging();
|
|
498
737
|
lines.push(` Could not load ${this.view === "stats" ? "statistics" : "patterns"}: ${this.loadError}`);
|
|
499
738
|
} else if (this.view === "stats" && stats) {
|
|
500
|
-
const reserved = headerLines.length +
|
|
501
|
-
lines.push(...this.visibleStatsLines(stats, contentWidth, Math.max(1, innerBudget - reserved)));
|
|
739
|
+
const reserved = headerLines.length + 2 + footerLines.length;
|
|
740
|
+
lines.push(...this.visibleStatsLines(stats, contentWidth, Math.max(1, innerBudget - reserved), practice));
|
|
502
741
|
} else {
|
|
503
742
|
if (this.actionError) lines.push(` Action failed: ${this.actionError}`);
|
|
504
743
|
if (items.length === 0) {
|
|
@@ -511,14 +750,7 @@ export class FluencyOverlay implements Component {
|
|
|
511
750
|
}
|
|
512
751
|
|
|
513
752
|
lines.push(` ${"─".repeat(Math.max(0, contentWidth - 2))}`);
|
|
514
|
-
|
|
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
|
-
}
|
|
753
|
+
lines.push(...footerLines);
|
|
522
754
|
|
|
523
755
|
const border = (text: string): string => this.callbacks?.theme.fg("border", text) ?? text;
|
|
524
756
|
const top = border(`╭${"─".repeat(contentWidth)}╮`);
|
|
@@ -532,6 +764,11 @@ export class FluencyOverlay implements Component {
|
|
|
532
764
|
}
|
|
533
765
|
}
|
|
534
766
|
|
|
767
|
+
export interface PracticeOverlayRuntime {
|
|
768
|
+
sessionSnoozed(): boolean;
|
|
769
|
+
resumeSession(): void;
|
|
770
|
+
}
|
|
771
|
+
|
|
535
772
|
export async function showFluencyOverlay(
|
|
536
773
|
ctx: ExtensionContext,
|
|
537
774
|
store: FluencyStore,
|
|
@@ -540,6 +777,7 @@ export async function showFluencyOverlay(
|
|
|
540
777
|
onMutationError?: (error: unknown) => void,
|
|
541
778
|
initialView: FluencyView = "inbox",
|
|
542
779
|
now: () => number = Date.now,
|
|
780
|
+
practiceRuntime?: PracticeOverlayRuntime,
|
|
543
781
|
): Promise<void> {
|
|
544
782
|
if (ctx.mode !== "tui") {
|
|
545
783
|
ctx.ui.notify("Pi Fluency inbox requires interactive TUI mode", "warning");
|
|
@@ -572,6 +810,22 @@ export async function showFluencyOverlay(
|
|
|
572
810
|
now: now(),
|
|
573
811
|
});
|
|
574
812
|
},
|
|
813
|
+
practice: () => {
|
|
814
|
+
const snapshot = store.getAnalyticsSnapshot();
|
|
815
|
+
const settings = store.getSettings();
|
|
816
|
+
const practiceSettings = store.getPracticeSettings();
|
|
817
|
+
return {
|
|
818
|
+
settings: practiceSettings,
|
|
819
|
+
targets: resolvePracticeTargets({
|
|
820
|
+
targets: practiceSettings.targets,
|
|
821
|
+
patterns: snapshot.patterns,
|
|
822
|
+
ignoredPatternKeys: new Set(settings.ignoredPatternKeys),
|
|
823
|
+
ignoredCategories: new Set(settings.ignoredCategories),
|
|
824
|
+
}),
|
|
825
|
+
sessionSnoozed: practiceRuntime?.sessionSnoozed() ?? false,
|
|
826
|
+
now: now(),
|
|
827
|
+
};
|
|
828
|
+
},
|
|
575
829
|
initialView,
|
|
576
830
|
ignoredBy: (pattern) => {
|
|
577
831
|
const settings = store.getSettings();
|
|
@@ -610,6 +864,8 @@ export async function showFluencyOverlay(
|
|
|
610
864
|
});
|
|
611
865
|
onProgressChanged?.();
|
|
612
866
|
},
|
|
867
|
+
activatePractice: (target) => store.activatePractice(now(), target),
|
|
868
|
+
setPracticeTarget: (target, selected) => store.setPracticeTarget(target, selected),
|
|
613
869
|
...(onMutationError ? { mutationError: onMutationError } : {}),
|
|
614
870
|
close: () => done(),
|
|
615
871
|
});
|