pi-fluency 0.1.3 → 0.2.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/README.md CHANGED
@@ -6,6 +6,7 @@ Pi Fluency analyzes human-authored prompts, lets you review possible English mis
6
6
 
7
7
  - Reviews only interactive prompts you write
8
8
  - Groups repeated findings into concrete recurring patterns
9
+ - Offers optional submit-time practice for recurring rules you select
9
10
  - Tracks accepted mistakes per 1,000 English words
10
11
  - Keeps one-off mistakes out of recurring-pattern counts
11
12
  - Works across Pi projects and sessions
@@ -42,22 +43,42 @@ Requires Pi 0.80.10 or newer.
42
43
  pi install npm:pi-fluency
43
44
  ```
44
45
 
45
- Run `/reload`, then `/fluency`. Choose an available analysis model and confirm provider disclosure. Analysis remains off until setup completes.
46
+ Run `/reload`, then `/fluency`. Choose an available analysis model and confirm provider disclosure for background analytics. Analysis remains off until setup completes. Optional preflight practice stays off until first Space selection atomically records preflight consent, enables practice, and selects focused rule.
46
47
 
47
48
  Useful commands:
48
49
 
49
50
  | Command | Action |
50
51
  | --- | --- |
51
52
  | `/fluency` | Set up Pi Fluency or open Inbox |
52
- | `/fluency stats` | Open 30-day analytics |
53
+ | `/fluency stats` | Open 30-day analytics and choose recurring rules |
54
+ | `/fluency practice` | Open Stats |
55
+ | `/fluency practice on` / `off` | Enable or bypass selected-rule practice |
56
+ | `/fluency practice resume` | End current session and five-hour snoozes |
57
+ | `/fluency practice reset` | Confirm, then clear practice selections and consent |
53
58
  | `/fluency pause` | Pause analysis and hide toolbar status |
54
59
  | `/fluency resume` | Resume analysis |
55
60
  | `/fluency model` | Change analysis model |
56
- | `/fluency status` | Show model, queue, and storage status |
57
- | `/fluency clear` | Confirm, then clear coaching and analytics history |
61
+ | `/fluency status` | Show model, queue, storage, and practice status |
62
+ | `/fluency clear` | Confirm, then clear coaching and analytics history; preserve practice selections |
58
63
 
59
64
  `Ctrl+Shift+L` opens Inbox after setup.
60
65
 
66
+ ## Practice selected rules
67
+
68
+ Practice is an optional analytical aid, not comprehensive grammar checking, automatic correction, or English instruction. Stats shows recurring rules as inline `[ ]` or `[x]` rows. First rule starts focused; use Up/Down or `j`/`k` to move focus and keep focused row in view, then press Space to toggle it. First Space records preflight consent, enables practice, and selects focused rule atomically; no separate consent prompt opens. This consent means eligible sanitized prompt prose may reach configured Fluency model before main request proceeds, including a draft you later keep instead of sending.
69
+
70
+ For idle, text-only interactive prompts, Pi Fluency checks selected rules at submit time. This sends full sanitized prose from eligible prompt to analysis provider and can add provider latency, bounded to one attempt with a maximum 30-second cap. Successful checks proceed immediately, and pressing Enter while checking bypasses the check. Editor keeps exact text Pi Fluency received while check runs. Clean result proceeds normally. Match opens checkpoint without rewriting text:
71
+
72
+ - **Edit** or **Esc** blocks submission and leaves received text in editor.
73
+ - **Send once** starts focused; press Enter to proceed once and keep practice active for later prompts.
74
+ - **Snooze session** proceeds once and bypasses checks for current conversation session, including reload or resume of same session file.
75
+ - **Snooze 5 hours** proceeds once and bypasses checks across Pi sessions until deadline.
76
+ - While checking, Enter selects **Send unchecked**, cancels bounded check, and proceeds; **Esc** returns to Edit.
77
+
78
+ Use `/fluency practice resume` to end either snooze. Technical errors, busy or timed-out analysis fail open and attempt to send unchecked. If editor cannot be cleared safely, Pi Fluency does not send and leaves draft visible. Adapter that ignores cancellation is quarantined process-locally: later prompts send unchecked without growing analysis queue until call settles or Pi process restarts.
79
+
80
+ Successful preflight result is reused for normal analytics. Edit/Esc attempt adds no Pi Fluency history; later edited submission is checked again. Practice selections are separate from history, so `/fluency clear` preserves them. Ignoring selected rule pauses matching practice until rule is restored.
81
+
61
82
  ## What it measures
62
83
 
63
84
  Pi Fluency counts only findings you accept. Every analyzer-classified English prompt contributes to word totals, including prompts with no findings.
@@ -66,7 +87,7 @@ Pi Fluency counts only findings you accept. Every analyzer-classified English pr
66
87
  accepted mistake rate = accepted occurrences / English words × 1000
67
88
  ```
68
89
 
69
- Stats covers trailing 30 local calendar days. It shows accepted rate, review coverage, one-off total, recurring patterns, and trends. Pattern list is sorted by accepted occurrence count. Pattern becomes recurring after accepted findings appear in at least two distinct prompts across retained history; one-offs remain in overall accepted totals and rate but stay out of recurring-pattern list and toolbar count.
90
+ Stats covers trailing 30 local calendar days. Its normalized chart always has 30 positions, labeled from `30 days ago` through `today`; `·` means no English words were recorded for that local day. It also shows accepted rate, review coverage, one-off total, recurring patterns, and trends. Pattern list is sorted by accepted occurrence count. Pattern becomes recurring after accepted findings appear in at least two distinct prompts across retained history; one-offs remain in overall accepted totals and rate but stay out of recurring-pattern list and toolbar count.
70
91
 
71
92
  Toolbar summarizes current activity and accepted-mistake trends:
72
93
 
@@ -89,6 +110,10 @@ Local analytics live under `~/.pi/agent/pi-fluency/`. History contains hashes, d
89
110
 
90
111
  Model findings can be incomplete or wrong. Accept only findings you agree with. Pi Fluency measures reviewed writing patterns; it does not assess fluency, guarantee improvement, or replace human instruction.
91
112
 
113
+ Preflight does not run for image-bearing submissions, slash commands, code-only input, RPC or extension-injected input, or active-stream `steer` / `followUp` input. Those boundaries avoid attachment loss and agent-control delays; eligible prompts may still use background analytics. Practice checks only selected concrete rules, so no checkpoint means neither proof that text is correct nor comprehensive analysis.
114
+
115
+ Input-handler order limits draft provenance. Extensions running before Pi Fluency can alter text Pi Fluency receives and can therefore alter text restored for Edit. Extensions running after Pi Fluency can alter or intercept text Pi Fluency allowed, so analyzed text is not guaranteed to equal final sent text. Place text-transforming or intercepting extensions compatibly; Pi Fluency cannot enforce original editor bytes or final send order.
116
+
92
117
  ## Project
93
118
 
94
119
  Product idea and product decisions are by Ihar Trafimovich. All code, tests, and documentation were written by AI.
@@ -1,8 +1,13 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { errantCategory } from "./taxonomy.js";
2
3
  import type {
3
4
  EnglishObservation,
4
5
  MistakeOccurrence,
5
6
  MistakePattern,
7
+ PracticeAnalysisContext,
8
+ PracticeMistakeCandidate,
9
+ PracticeTarget,
10
+ ResolvedPracticeTarget,
6
11
  } from "./types.js";
7
12
  import type { ErrantCategory } from "./taxonomy.js";
8
13
 
@@ -10,6 +15,8 @@ const ENGLISH_WORD = /[\p{L}\p{N}]+(?:['’][\p{L}\p{N}]+)*/gu;
10
15
  const SPARK_GLYPHS = "▁▂▃▄▅▆▇█";
11
16
  const DATE_KEY = /^\d{4}-\d{2}-\d{2}$/;
12
17
  const TREND_DAYS = 30;
18
+ const MAX_KNOWN_PATTERNS = 500;
19
+ const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/;
13
20
 
14
21
  /** Count word-like runs after the analyzer has classified sanitized prose as English. */
15
22
  export function countEnglishWords(prose: string): number {
@@ -20,7 +27,11 @@ export type RuleTrend = "improving" | "worsening" | "stable" | "new";
20
27
 
21
28
  export interface RuleAnalytics {
22
29
  patternId: string;
30
+ /** Deterministic UI identity derived from explanation; never persist or render it. */
31
+ rowKey: string;
23
32
  explanation: string;
33
+ /** Complete current group membership for durable selection. Never render it. */
34
+ memberPatternKeys: string[];
24
35
  accepted: number;
25
36
  ratePerThousand: number | undefined;
26
37
  sparkline: string;
@@ -35,6 +46,7 @@ export interface FluencyAnalytics {
35
46
  currentRatePerThousand?: number;
36
47
  periodRatePerThousand?: number;
37
48
  toolbarSparkline: string;
49
+ dailyRateSparkline: string;
38
50
  englishWords: number;
39
51
  accepted: number;
40
52
  dismissed: number;
@@ -71,6 +83,19 @@ export function renderSparkline(values: Array<number | undefined>): string {
71
83
  }).join("");
72
84
  }
73
85
 
86
+ function renderDailyRateSparkline(values: Array<number | undefined>): string {
87
+ const finite = values.filter((value): value is number =>
88
+ typeof value === "number" && Number.isFinite(value));
89
+ if (finite.length === 0) return values.map(() => "·").join("");
90
+ const maximum = Math.max(...finite);
91
+ return values.map((value) => {
92
+ if (value === undefined || !Number.isFinite(value)) return "·";
93
+ if (maximum <= 0) return SPARK_GLYPHS[0]!;
94
+ const index = Math.round(value / maximum * (SPARK_GLYPHS.length - 1));
95
+ return SPARK_GLYPHS[Math.max(0, Math.min(SPARK_GLYPHS.length - 1, index))]!;
96
+ }).join("");
97
+ }
98
+
74
99
  export function classifyRuleTrend(
75
100
  current: number | undefined,
76
101
  previous: number | undefined,
@@ -116,6 +141,122 @@ interface RuleGroup {
116
141
  patternId: string;
117
142
  explanation: string;
118
143
  patternIds: Set<string>;
144
+ memberPatternKeys: Set<string>;
145
+ }
146
+
147
+ function assertValidTarget(target: PracticeTarget): void {
148
+ if (target.explanation.length === 0 || target.explanation.length > 500
149
+ || CONTROL_CHARACTER.test(target.explanation)
150
+ || target.memberPatternKeys.length === 0 || target.memberPatternKeys.length > 500
151
+ || target.memberPatternKeys.some((key) => key.length === 0 || key.length > 500 || CONTROL_CHARACTER.test(key))) {
152
+ throw new Error("Invalid practice target");
153
+ }
154
+ }
155
+
156
+ function copyTarget(target: PracticeTarget): PracticeTarget {
157
+ assertValidTarget(target);
158
+ return { explanation: target.explanation, memberPatternKeys: [...target.memberPatternKeys] };
159
+ }
160
+
161
+ /** Stable transient identity for a concrete rule row. Never persist or display this value. */
162
+ export function practiceRuleRowKey(explanation: string): string {
163
+ if (explanation.length === 0 || explanation.length > 500 || CONTROL_CHARACTER.test(explanation)) {
164
+ throw new Error("Invalid practice target");
165
+ }
166
+ return `rule-${createHash("sha256").update(explanation).digest("hex").slice(0, 16)}`;
167
+ }
168
+
169
+ export interface ResolvePracticeTargetsInput {
170
+ targets: readonly PracticeTarget[];
171
+ patterns: readonly MistakePattern[];
172
+ ignoredPatternKeys: ReadonlySet<string>;
173
+ ignoredCategories: ReadonlySet<ErrantCategory>;
174
+ }
175
+
176
+ /** Project durable selections against current patterns without mutating either input. */
177
+ export function resolvePracticeTargets(input: ResolvePracticeTargetsInput): ResolvedPracticeTarget[] {
178
+ return input.targets.map((target) => {
179
+ assertValidTarget(target);
180
+ const durableKeys = new Set(target.memberPatternKeys);
181
+ const current = input.patterns.filter((pattern) =>
182
+ durableKeys.has(pattern.patternKey) || pattern.explanation === target.explanation);
183
+ const currentByKey = new Map(current.map((pattern) => [pattern.patternKey, pattern]));
184
+ const memberPatternKeys = [...new Set([
185
+ ...target.memberPatternKeys,
186
+ ...current.map((pattern) => pattern.patternKey),
187
+ ])].sort((left, right) => left.localeCompare(right));
188
+ const coachingEnabled = memberPatternKeys.some((patternKey) => {
189
+ if (input.ignoredPatternKeys.has(patternKey)) return false;
190
+ const pattern = currentByKey.get(patternKey);
191
+ return pattern === undefined || !input.ignoredCategories.has(errantCategory(pattern.errorType));
192
+ });
193
+ return {
194
+ rowKey: practiceRuleRowKey(target.explanation),
195
+ explanation: target.explanation,
196
+ memberPatternKeys,
197
+ currentPatternKeys: [...new Set(current.map((pattern) => pattern.patternKey))]
198
+ .sort((left, right) => left.localeCompare(right)),
199
+ coachingEnabled,
200
+ };
201
+ });
202
+ }
203
+
204
+ /** Return matching selected target unless candidate is suppressed by Ignore policy. */
205
+ export function selectedTargetForMistake(
206
+ candidate: PracticeMistakeCandidate,
207
+ targets: readonly PracticeTarget[],
208
+ ignoredPatternKeys: ReadonlySet<string>,
209
+ ignoredCategories: ReadonlySet<ErrantCategory>,
210
+ ): PracticeTarget | undefined {
211
+ for (const target of targets) assertValidTarget(target);
212
+ if (CONTROL_CHARACTER.test(candidate.explanation) || CONTROL_CHARACTER.test(candidate.patternKey)) {
213
+ throw new Error("Invalid practice candidate");
214
+ }
215
+ if (ignoredPatternKeys.has(candidate.patternKey)
216
+ || ignoredCategories.has(errantCategory(candidate.errorType))) return undefined;
217
+ const target = targets.find((item) =>
218
+ item.explanation === candidate.explanation || item.memberPatternKeys.includes(candidate.patternKey));
219
+ return target === undefined ? undefined : copyTarget(target);
220
+ }
221
+
222
+ function comparePatternRecency(left: MistakePattern, right: MistakePattern): number {
223
+ return right.lastSeenAt - left.lastSeenAt
224
+ || left.patternKey.localeCompare(right.patternKey)
225
+ || left.id.localeCompare(right.id);
226
+ }
227
+
228
+ /** Prioritize selected patterns while keeping complete target descriptors outside bounded context. */
229
+ export function selectPracticeAnalysisContext(
230
+ targets: readonly PracticeTarget[],
231
+ patterns: readonly MistakePattern[],
232
+ maximumPatterns = MAX_KNOWN_PATTERNS,
233
+ ): PracticeAnalysisContext {
234
+ if (!Number.isSafeInteger(maximumPatterns) || maximumPatterns < 0) {
235
+ throw new Error("Invalid known pattern limit");
236
+ }
237
+ const targetDescriptors = targets.map(copyTarget);
238
+ const selected: MistakePattern[] = [];
239
+ const selectedIds = new Set<string>();
240
+ for (const target of targetDescriptors) {
241
+ const memberKeys = new Set(target.memberPatternKeys);
242
+ const matches = patterns
243
+ .filter((pattern) => memberKeys.has(pattern.patternKey) || pattern.explanation === target.explanation)
244
+ .sort(comparePatternRecency);
245
+ for (const pattern of matches) {
246
+ if (selectedIds.has(pattern.id)) continue;
247
+ selectedIds.add(pattern.id);
248
+ selected.push(pattern);
249
+ }
250
+ }
251
+ const remaining = patterns
252
+ .filter((pattern) => !selectedIds.has(pattern.id))
253
+ .sort(comparePatternRecency);
254
+ return {
255
+ targetDescriptors,
256
+ patterns: [...selected, ...remaining]
257
+ .slice(0, maximumPatterns)
258
+ .map((pattern) => ({ ...pattern })),
259
+ };
119
260
  }
120
261
 
121
262
  export function computeFluencyAnalytics(input: AnalyticsInput): FluencyAnalytics {
@@ -149,6 +290,11 @@ export function computeFluencyAnalytics(input: AnalyticsInput): FluencyAnalytics
149
290
  const window = totals(end, 7);
150
291
  return ratePerThousand(window.accepted, window.words);
151
292
  });
293
+ const dailyRates = Array.from({ length: TREND_DAYS }, (_, index) => {
294
+ const date = shiftDate(today, index - (TREND_DAYS - 1));
295
+ const day = totals(date, 1);
296
+ return ratePerThousand(day.accepted, day.words);
297
+ });
152
298
  const currentSeven = totals(today, 7);
153
299
  const currentThirty = totals(today, TREND_DAYS);
154
300
  const previousEnd = shiftDate(today, -TREND_DAYS);
@@ -168,12 +314,14 @@ export function computeFluencyAnalytics(input: AnalyticsInput): FluencyAnalytics
168
314
  const existing = groupsByExplanation.get(explanation);
169
315
  if (existing) {
170
316
  existing.patternIds.add(pattern.id);
317
+ existing.memberPatternKeys.add(pattern.patternKey);
171
318
  if (pattern.id.localeCompare(existing.patternId) < 0) existing.patternId = pattern.id;
172
319
  } else {
173
320
  groupsByExplanation.set(explanation, {
174
321
  patternId: pattern.id,
175
322
  explanation,
176
323
  patternIds: new Set([pattern.id]),
324
+ memberPatternKeys: new Set([pattern.patternKey]),
177
325
  });
178
326
  }
179
327
  }
@@ -217,7 +365,9 @@ export function computeFluencyAnalytics(input: AnalyticsInput): FluencyAnalytics
217
365
  : undefined;
218
366
  return [{
219
367
  patternId: group.patternId,
368
+ rowKey: practiceRuleRowKey(group.explanation),
220
369
  explanation: group.explanation,
370
+ memberPatternKeys: [...group.memberPatternKeys].sort((left, right) => left.localeCompare(right)),
221
371
  accepted,
222
372
  ratePerThousand: currentRate,
223
373
  sparkline: renderSparkline(sparklineValues),
@@ -245,6 +395,7 @@ export function computeFluencyAnalytics(input: AnalyticsInput): FluencyAnalytics
245
395
  ...(currentRatePerThousand === undefined ? {} : { currentRatePerThousand }),
246
396
  ...(periodRatePerThousand === undefined ? {} : { periodRatePerThousand }),
247
397
  toolbarSparkline: renderSparkline(trailingRates),
398
+ dailyRateSparkline: renderDailyRateSparkline(dailyRates),
248
399
  englishWords: currentThirty.words,
249
400
  accepted: currentThirty.accepted,
250
401
  dismissed: currentThirty.dismissed,
@@ -6,6 +6,7 @@ import {
6
6
  type AnalysisResult,
7
7
  type CollectedPrompt,
8
8
  type MistakePattern,
9
+ type PracticeTarget,
9
10
  type RawAnalysisResult,
10
11
  } from "./types.js";
11
12
  import {
@@ -29,16 +30,39 @@ export interface Analyzer {
29
30
  prompt: CollectedPrompt,
30
31
  activePatterns: MistakePattern[],
31
32
  signal: AbortSignal,
33
+ selectedTargets?: readonly PracticeTarget[],
32
34
  ): Promise<AnalysisResult>;
33
35
  }
34
36
 
35
- export function buildAnalysisPrompt(prompt: CollectedPrompt, activePatterns: MistakePattern[]): string {
36
- const knownPatterns = activePatterns
37
+ function selectedPatternKeys(
38
+ activePatterns: readonly MistakePattern[],
39
+ selectedTargets: readonly PracticeTarget[],
40
+ ): Set<string> {
41
+ const selectedKeys = new Set(selectedTargets.flatMap((target) => target.memberPatternKeys));
42
+ const selectedExplanations = new Set(selectedTargets.map((target) => target.explanation));
43
+ return new Set(activePatterns
44
+ .filter((pattern) => selectedKeys.has(pattern.patternKey) || selectedExplanations.has(pattern.explanation))
45
+ .map((pattern) => pattern.patternKey));
46
+ }
47
+
48
+ export function buildAnalysisPrompt(
49
+ prompt: CollectedPrompt,
50
+ activePatterns: MistakePattern[],
51
+ selectedTargets: readonly PracticeTarget[] = [],
52
+ ): string {
53
+ const priorityKeys = selectedPatternKeys(activePatterns, selectedTargets);
54
+ const orderedPatterns = [
55
+ ...activePatterns.filter((pattern) => priorityKeys.has(pattern.patternKey)),
56
+ ...activePatterns.filter((pattern) => !priorityKeys.has(pattern.patternKey)),
57
+ ];
58
+ const knownPatterns = orderedPatterns
37
59
  .slice(0, MAX_KNOWN_PATTERNS)
38
60
  .map(({ patternKey, explanation, errorType }) => ({ patternKey, explanation, errorType }));
61
+ const includedKeys = new Set(knownPatterns.map((pattern) => pattern.patternKey));
39
62
  return JSON.stringify({
40
63
  prose: prompt.prose,
41
64
  knownPatterns,
65
+ priorityPatternKeys: [...priorityKeys].filter((key) => includedKeys.has(key)),
42
66
  allowedErrorTypes: ERRANT_ERROR_TYPES,
43
67
  allowedContextScopes: CONTEXT_SCOPES,
44
68
  });
@@ -108,6 +132,7 @@ Do not report style preferences, capitalization of product names, or informal-bu
108
132
  A demonstrated fix must match one supplied knownPatterns pattern and quote comparable correct evidence.
109
133
  Never infer a demonstrated fix merely because an earlier mistake is absent.
110
134
  Reuse an existing knownPatterns patternKey for the same rule; mint a new namespaced lowercase key only when no known rule matches.
135
+ Check priorityPatternKeys carefully before other known patterns. This changes attention only; still return every genuine finding in the full prose.
111
136
  Keep every explanation under 240 characters. Return JSON only.`;
112
137
 
113
138
  export class ModelAnalyzer implements Analyzer {
@@ -121,6 +146,7 @@ export class ModelAnalyzer implements Analyzer {
121
146
  prompt: CollectedPrompt,
122
147
  activePatterns: MistakePattern[],
123
148
  signal: AbortSignal,
149
+ selectedTargets: readonly PracticeTarget[] = [],
124
150
  ): Promise<AnalysisResult> {
125
151
  let auth: Awaited<ReturnType<ModelRegistry["getApiKeyAndHeaders"]>>;
126
152
  try {
@@ -134,7 +160,7 @@ export class ModelAnalyzer implements Analyzer {
134
160
 
135
161
  const message: UserMessage = {
136
162
  role: "user",
137
- content: buildAnalysisPrompt(prompt, activePatterns),
163
+ content: buildAnalysisPrompt(prompt, activePatterns, selectedTargets),
138
164
  timestamp: Date.now(),
139
165
  };
140
166
  const response = await complete(
@@ -0,0 +1,259 @@
1
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type Component,
4
+ Key,
5
+ matchesKey,
6
+ truncateToWidth,
7
+ visibleWidth,
8
+ wrapTextWithAnsi,
9
+ } from "@earendil-works/pi-tui";
10
+ import { sanitizeTerminalLabel } from "./sanitize.js";
11
+ import type { AnalyzerMistake, PracticeTarget } from "./types.js";
12
+
13
+ export type CoachingOverlayDecision =
14
+ | "edit"
15
+ | "send-unchecked"
16
+ | "send-once"
17
+ | "snooze-session"
18
+ | "snooze-five-hours"
19
+ | "clean"
20
+ | "technical-failure";
21
+
22
+ export type CoachingCheckResult =
23
+ | { kind: "matches"; mistakes: AnalyzerMistake[]; targets: PracticeTarget[] }
24
+ | { kind: "clean" }
25
+ | { kind: "failure" };
26
+
27
+ export type CoachingSnoozeDecision = "snooze-session" | "snooze-five-hours";
28
+ export type CoachingSnoozeHandler = (decision: CoachingSnoozeDecision) => Promise<void>;
29
+
30
+ interface CoachingOverlayOptions {
31
+ tui: { requestRender(): void; terminal: { rows: number } };
32
+ theme?: Theme;
33
+ keybindings?: { matches(data: string, binding: string): boolean };
34
+ finish: (decision: CoachingOverlayDecision) => void;
35
+ saveSnooze?: CoachingSnoozeHandler;
36
+ }
37
+
38
+ type Mode = "checking" | "matched" | "saving";
39
+ const ACTIONS = ["Edit", "Send once", "Snooze session", "Snooze 5 hours"] as const;
40
+
41
+ function safe(value: string, maximum = 500): string {
42
+ return sanitizeTerminalLabel(value, maximum) || "—";
43
+ }
44
+
45
+ /** Keyboard-first submit checkpoint. Contains validated analysis only, never received draft text. */
46
+ export class CoachingOverlay implements Component {
47
+ private mode: Mode = "checking";
48
+ private mistakes: AnalyzerMistake[] = [];
49
+ private targets: PracticeTarget[] = [];
50
+ private selectedAction = 1;
51
+ private detailOffset = 0;
52
+ private visibleDetailCount = 1;
53
+ private disposed = false;
54
+
55
+ constructor(private readonly options: CoachingOverlayOptions) {}
56
+
57
+ setMatches(mistakes: AnalyzerMistake[], targets: PracticeTarget[]): void {
58
+ if (this.disposed) return;
59
+ this.mode = "matched";
60
+ this.mistakes = [...mistakes];
61
+ this.targets = targets.map((target) => ({ ...target, memberPatternKeys: [...target.memberPatternKeys] }));
62
+ this.options.tui.requestRender();
63
+ }
64
+
65
+ setSaving(saving: boolean): void {
66
+ if (this.disposed) return;
67
+ this.mode = saving ? "saving" : "matched";
68
+ this.options.tui.requestRender();
69
+ }
70
+
71
+ invalidate(): void { /* No cached layout. */ }
72
+ dispose(): void { this.disposed = true; }
73
+
74
+ private matches(data: string, binding: string): boolean {
75
+ return this.options.keybindings?.matches(data, binding) ?? false;
76
+ }
77
+
78
+ handleInput(data: string): void {
79
+ if (this.disposed || this.mode === "saving") return;
80
+ const cancel = data === Key.escape || this.matches(data, "tui.select.cancel");
81
+ if (this.mode === "checking") {
82
+ if (cancel) this.options.finish("edit");
83
+ else if (data === Key.enter || this.matches(data, "tui.select.confirm")) {
84
+ this.options.finish("send-unchecked");
85
+ }
86
+ return;
87
+ }
88
+ if (cancel) {
89
+ this.options.finish("edit");
90
+ return;
91
+ }
92
+ if (data === Key.enter || this.matches(data, "tui.select.confirm")) {
93
+ const action = (["edit", "send-once", "snooze-session", "snooze-five-hours"] as const)[this.selectedAction]!;
94
+ if (action === "snooze-session" || action === "snooze-five-hours") this.beginSnooze(action);
95
+ else this.options.finish(action);
96
+ return;
97
+ }
98
+ if (data.toLowerCase() === "e") {
99
+ this.options.finish("edit");
100
+ return;
101
+ }
102
+ if (data.toLowerCase() === "t") this.beginSnooze("snooze-session");
103
+ else if (data === "5") this.beginSnooze("snooze-five-hours");
104
+ else if (data === "j" || this.matches(data, "tui.select.down")) {
105
+ this.selectedAction = Math.min(ACTIONS.length - 1, this.selectedAction + 1);
106
+ this.options.tui.requestRender();
107
+ } else if (data === "k" || this.matches(data, "tui.select.up")) {
108
+ this.selectedAction = Math.max(0, this.selectedAction - 1);
109
+ this.options.tui.requestRender();
110
+ } else if (this.matches(data, "tui.select.pageDown")) {
111
+ this.detailOffset = Math.min(Math.max(0, this.orderedDetails().length - 1), this.detailOffset + this.visibleDetailCount);
112
+ this.options.tui.requestRender();
113
+ } else if (this.matches(data, "tui.select.pageUp")) {
114
+ this.detailOffset = Math.max(0, this.detailOffset - this.visibleDetailCount);
115
+ this.options.tui.requestRender();
116
+ }
117
+ }
118
+
119
+ private beginSnooze(action: CoachingSnoozeDecision): void {
120
+ // showCoachingOverlay reserves this terminal outcome before persistence starts.
121
+ this.options.finish(action);
122
+ }
123
+
124
+ private mistakeGroups(): Array<{ label: string; mistakes: AnalyzerMistake[] }> {
125
+ const assigned = new Set<number>();
126
+ const groups = this.targets.map((target) => {
127
+ const mistakes = this.mistakes.filter((mistake, index) => {
128
+ if (assigned.has(index)) return false;
129
+ const matches = target.memberPatternKeys.includes(mistake.patternKey)
130
+ || target.explanation === mistake.explanation;
131
+ if (matches) assigned.add(index);
132
+ return matches;
133
+ });
134
+ return { label: target.explanation, mistakes };
135
+ }).filter((group) => group.mistakes.length > 0);
136
+ for (const [index, mistake] of this.mistakes.entries()) {
137
+ if (!assigned.has(index)) groups.push({ label: mistake.explanation, mistakes: [mistake] });
138
+ }
139
+ return groups;
140
+ }
141
+
142
+ private orderedDetails(): Array<{ label: string; mistake: AnalyzerMistake }> {
143
+ return this.mistakeGroups().flatMap((group) =>
144
+ group.mistakes.map((mistake) => ({ label: group.label, mistake })));
145
+ }
146
+
147
+ render(width: number): string[] {
148
+ const contentWidth = Math.max(1, Math.min(width - 2, 88));
149
+ const border = (text: string): string => this.options.theme?.fg("border", text) ?? text;
150
+ const lines: string[] = [];
151
+ if (this.mode === "checking") {
152
+ lines.push(" Checking selected fluency rules…", "", " Enter Send unchecked esc Edit");
153
+ } else {
154
+ const details = this.orderedDetails();
155
+ const actionLines = this.mode === "saving"
156
+ ? [" Saving snooze…"]
157
+ : [...ACTIONS.map((action, index) => ` ${index === this.selectedAction ? "›" : " "} ${action}`),
158
+ " Enter confirm esc Edit"];
159
+ // Border, heading, action separator, and actions stay visible even on short terminals.
160
+ const detailBudget = Math.max(3, this.options.tui.terminal.rows - actionLines.length - 5);
161
+ const detailLines: string[] = [];
162
+ let shown = 0;
163
+ let priorLabel: string | undefined;
164
+ const shownLabels = new Set<string>();
165
+ for (const detail of details.slice(this.detailOffset)) {
166
+ if (!shownLabels.has(detail.label) && shownLabels.size >= 3) break;
167
+ const block: string[] = [];
168
+ if (detail.label !== priorLabel) {
169
+ block.push(" ────────────────────────────────────────", ` Rule: ${safe(detail.label)}`);
170
+ }
171
+ for (const [label, value] of [
172
+ ["Original", detail.mistake.sourceExcerpt],
173
+ ["Suggestion", detail.mistake.correctedExcerpt],
174
+ ["Why", detail.mistake.explanation],
175
+ ] as const) {
176
+ const wrapped = wrapTextWithAnsi(`${label}: ${safe(value)}`, Math.max(10, contentWidth - 2));
177
+ block.push(...wrapped.map((line) => ` ${line}`));
178
+ }
179
+ if (shown > 0 && detailLines.length + block.length > detailBudget) break;
180
+ detailLines.push(...block.slice(0, Math.max(0, detailBudget - detailLines.length)));
181
+ shown += 1;
182
+ shownLabels.add(detail.label);
183
+ priorLabel = detail.label;
184
+ if (detailLines.length >= detailBudget) break;
185
+ }
186
+ this.visibleDetailCount = Math.max(1, shown);
187
+ const hidden = Math.max(0, details.length - this.detailOffset - shown);
188
+ lines.push(` Practice check · ${this.mistakes.length} ${this.mistakes.length === 1 ? "match" : "matches"}${hidden > 0 ? ` · +${hidden} more` : ""}`);
189
+ lines.push(...detailLines);
190
+ if (this.detailOffset > 0 || hidden > 0) {
191
+ lines.push(` PageUp/PageDown details · ${this.detailOffset + 1}-${this.detailOffset + shown} of ${details.length} matches`);
192
+ }
193
+ lines.push(" ────────────────────────────────────────", ...actionLines);
194
+ }
195
+ const top = border(`╭${"─".repeat(contentWidth)}╮`);
196
+ const bottom = border(`╰${"─".repeat(contentWidth)}╯`);
197
+ return [top, ...lines.map((line) => {
198
+ const content = truncateToWidth(line, contentWidth, "");
199
+ return `${border("│")}${content}${" ".repeat(Math.max(0, contentWidth - visibleWidth(content)))}${border("│")}`;
200
+ }), bottom];
201
+ }
202
+ }
203
+
204
+ export async function showCoachingOverlay(
205
+ ctx: ExtensionContext,
206
+ check: Promise<CoachingCheckResult>,
207
+ signal?: AbortSignal,
208
+ saveSnooze?: CoachingSnoozeHandler,
209
+ ): Promise<CoachingOverlayDecision> {
210
+ if (ctx.mode !== "tui") return "technical-failure";
211
+ let overlay: CoachingOverlay | undefined;
212
+ let close: (() => void) | undefined;
213
+ let settled = false;
214
+ let resolveDecision!: (decision: CoachingOverlayDecision) => void;
215
+ const decision = new Promise<CoachingOverlayDecision>((resolve) => { resolveDecision = resolve; });
216
+ const finish = (value: CoachingOverlayDecision): void => {
217
+ if (settled) return;
218
+ settled = true;
219
+ if ((value === "snooze-session" || value === "snooze-five-hours") && saveSnooze) {
220
+ // Reserve snooze as terminal winner before persistence. Abort/shutdown now lose without side effects.
221
+ overlay?.setSaving(true);
222
+ void Promise.resolve().then(() => saveSnooze(value)).catch(() => undefined).finally(() => {
223
+ overlay?.dispose();
224
+ close?.();
225
+ resolveDecision(value);
226
+ });
227
+ return;
228
+ }
229
+ overlay?.dispose();
230
+ close?.();
231
+ resolveDecision(value);
232
+ };
233
+ const abort = (): void => finish("send-unchecked");
234
+ signal?.addEventListener("abort", abort, { once: true });
235
+ try {
236
+ const custom = Promise.resolve(ctx.ui.custom<void>((tui, theme, keybindings, done) => {
237
+ close = done;
238
+ overlay = new CoachingOverlay({ tui, theme, keybindings, finish, ...(saveSnooze ? { saveSnooze } : {}) });
239
+ void check.then(
240
+ (result) => {
241
+ if (result.kind === "matches") overlay?.setMatches(result.mistakes, result.targets);
242
+ else finish(result.kind === "clean" ? "clean" : "technical-failure");
243
+ },
244
+ () => finish("technical-failure"),
245
+ );
246
+ if (signal?.aborted) abort();
247
+ return overlay;
248
+ }, {
249
+ overlay: true,
250
+ overlayOptions: { anchor: "center", width: "75%", minWidth: 56, maxHeight: "85%" },
251
+ })).catch(() => finish("technical-failure"));
252
+ await Promise.race([decision, custom]);
253
+ return await decision;
254
+ } finally {
255
+ signal?.removeEventListener("abort", abort);
256
+ overlay?.dispose();
257
+ close?.();
258
+ }
259
+ }