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.
@@ -11,10 +11,20 @@ import {
11
11
  type TUI,
12
12
  wrapTextWithAnsi,
13
13
  } from "@earendil-works/pi-tui";
14
- import { computeFluencyAnalytics, type FluencyAnalytics, type RuleAnalytics } from "./analytics.js";
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 { ReviewPattern } from "./types.js";
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
- return message.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?").trim() || "Unknown error";
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 "✦ new";
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 statsLines(stats: FluencyAnalytics, width: number): string[] {
418
- const body: string[] = [];
419
- const append = (text = ""): void => {
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) body.push("");
422
- else for (const line of wrapped) body.push(` ${line}`);
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("Fluency trend · 30 days");
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 ✦ ${stats.trendCounts.new} new`);
613
+ append(`↓ ${stats.trendCounts.improving} improving ↑ ${stats.trendCounts.worsening} worsening → ${stats.trendCounts.stable} stable`);
448
614
  append();
449
- if (stats.rules.length === 0) {
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
- } 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
- }
623
+ return { lines };
458
624
  }
459
- return body;
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.statsLines(stats, width);
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
- return body.slice(this.detailOffset, this.detailOffset + available);
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 + 1 + FOOTER_LINES;
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
- 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
- }
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
  });