@graelo/pi-defer-modal 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.
package/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # pi-defer-modal
2
+
3
+ A Pi extension that defers modal dialogs while you're typing, preventing interruptions to your workflow.
4
+
5
+ ## Problem
6
+
7
+ When extensions display modal dialogs (using `ctx.ui.select()`, `ctx.ui.confirm()`, or `ctx.ui.input()`), these modals grab keyboard focus immediately. If you're in the middle of typing a message, the modal **interrupts your typing** — keystrokes land in the modal instead of the editor, breaking your train of thought.
8
+
9
+ ## Solution
10
+
11
+ This extension intercepts modal calls from any extension and defers them until you pause typing or submit your input. The tool calls stay blocked (as they should), but the UI presentation is delayed until you're ready.
12
+
13
+ ## Features
14
+
15
+ - **Non-interrupting modals**: Modals wait until you pause typing before appearing
16
+ - **Configurable modal types**: Choose which modal types to defer (select, confirm, input)
17
+ - **Adjustable timing**: Configure how long to wait after your last keystroke
18
+ - **Safety ceiling**: Maximum deferral time prevents tools from hanging indefinitely
19
+ - **Status indicator**: Optional visual indicator when modals are being deferred
20
+ - **Works with any extension**: Transparently intercepts modals from all extensions
21
+
22
+ ## Installation
23
+
24
+ ### From npm (recommended)
25
+
26
+ ```bash
27
+ pnpm add pi-defer-modal
28
+ ```
29
+
30
+ ### Manual installation
31
+
32
+ 1. Clone or download this repository
33
+ 2. Place the `pi-defer-modal` directory in one of Pi's extension locations:
34
+ - Project-local: `.pi/extensions/pi-defer-modal/`
35
+ - Global: `~/.pi/agent/extensions/pi-defer-modal/`
36
+
37
+ ## Configuration
38
+
39
+ Create a `config.json` file in the extension directory:
40
+
41
+ ```json
42
+ {
43
+ "enabled": true,
44
+ "modalTypes": ["select", "confirm", "input"],
45
+ "quietMs": 1500,
46
+ "maxDeferMs": 30000,
47
+ "showStatusIndicator": true,
48
+ "statusText": "⏸ modal pending — pause to review"
49
+ }
50
+ ```
51
+
52
+ ### Configuration Options
53
+
54
+ | Option | Type | Default | Description |
55
+ |--------|------|---------|-------------|
56
+ | `enabled` | boolean | `false` | Enable or disable modal deferral |
57
+ | `modalTypes` | string[] | `["select", "confirm", "input"]` | Which modal types to defer |
58
+ | `quietMs` | number | `1500` | Milliseconds of inactivity before showing deferred modals |
59
+ | `maxDeferMs` | number | `30000` | Maximum time to defer a modal (prevents hanging) |
60
+ | `showStatusIndicator` | boolean | `true` | Show a status indicator when modals are deferred |
61
+ | `statusText` | string | `"⏸ modal pending — pause to review"` | Text to show in the status indicator |
62
+
63
+ ### Configuration Locations
64
+
65
+ The extension checks for configuration in these locations (in order of priority):
66
+
67
+ 1. Project-local: `.pi/extensions/pi-defer-modal/config.json`
68
+ 2. Global: `~/.pi/agent/extensions/pi-defer-modal/config.json`
69
+
70
+ ## Commands
71
+
72
+ ### `/defer-modal-toggle`
73
+
74
+ Toggle modal deferral on or off.
75
+
76
+ ```text
77
+ /defer-modal-toggle
78
+ ```
79
+
80
+ ### `/defer-modal-config`
81
+
82
+ Show the current configuration.
83
+
84
+ ```text
85
+ /defer-modal-config
86
+ ```
87
+
88
+ ### `/defer-modal-reload`
89
+
90
+ Reload configuration from file (useful after editing config.json).
91
+
92
+ ```text
93
+ /defer-modal-reload
94
+ ```
95
+
96
+ ## How It Works
97
+
98
+ 1. **Typing Tracking**: The extension subscribes to `ctx.ui.onTerminalInput` to track when you type
99
+ 2. **Modal Interception**: It wraps the UI methods (`select`, `confirm`, `input`) to intercept modal calls
100
+ 3. **Deferral Logic**: When a modal is called, if you're actively typing, the extension waits until:
101
+ - You pause typing for `quietMs` milliseconds, OR
102
+ - You submit your input (press Enter), OR
103
+ - The maximum deferral time (`maxDeferMs`) elapses
104
+ 4. **Status Indicator**: While waiting, an optional status message shows that modals are pending
105
+
106
+ ## Example Usage
107
+
108
+ With this extension enabled:
109
+
110
+ 1. You start typing a command: `git commit -m "fix: ...`
111
+ 2. An extension (like pi-permission-system) tries to show a permission modal
112
+ 3. Instead of interrupting you, the modal waits
113
+ 4. You finish typing and pause for 1.5 seconds (default `quietMs`)
114
+ 5. The permission modal appears, ready for your input
115
+
116
+ ## Compatibility
117
+
118
+ - Works with Pi v0.2.0 and later
119
+ - Compatible with all extensions that use standard UI modal methods
120
+ - No changes required to existing extensions
121
+
122
+ ## Development
123
+
124
+ ```bash
125
+ # Install dependencies
126
+ pnpm install
127
+
128
+ # Type check
129
+ pnpm run check
130
+
131
+ # Build
132
+ pnpm run build
133
+ ```
134
+
135
+ ## License
136
+
137
+ MIT
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@graelo/pi-defer-modal",
3
+ "version": "0.1.0",
4
+ "description": "Defer modal dialogs while the user is typing to prevent interruption",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "check": "tsc --noEmit"
10
+ },
11
+ "devDependencies": {
12
+ "@earendil-works/pi-coding-agent": "*",
13
+ "typescript": "^6.0.3"
14
+ },
15
+ "pi": {
16
+ "extensions": [
17
+ "./src/index.ts"
18
+ ]
19
+ },
20
+ "keywords": [
21
+ "pi-package",
22
+ "pi",
23
+ "extension",
24
+ "modal",
25
+ "dialog",
26
+ "typing",
27
+ "defer",
28
+ "non-interrupting"
29
+ ],
30
+ "author": "",
31
+ "license": "MIT"
32
+ }
package/src/config.ts ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Configuration for the pi-defer-modal extension.
3
+ */
4
+
5
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join, resolve } from "node:path";
9
+
10
+ /**
11
+ * Configuration options for modal deferral behavior.
12
+ */
13
+ export interface DeferModalConfig {
14
+ /**
15
+ * Enable or disable modal deferral while typing.
16
+ * When false, modals appear immediately as normal.
17
+ */
18
+ enabled: boolean;
19
+
20
+ /**
21
+ * Modal types to defer while typing.
22
+ * Supported types: "select", "confirm", "input"
23
+ */
24
+ modalTypes: string[];
25
+
26
+ /**
27
+ * Idle gap in milliseconds with no keystrokes that counts as "paused typing".
28
+ * Once this gap elapses, deferred modals will appear.
29
+ * Default: 1500ms
30
+ */
31
+ quietMs: number;
32
+
33
+ /**
34
+ * Maximum total deferral time in milliseconds.
35
+ * The modal will appear after this time even if the user never stops typing.
36
+ * This prevents "block the tool" from becoming "hang the tool".
37
+ * Default: 30000ms (30 seconds)
38
+ */
39
+ maxDeferMs: number;
40
+
41
+ /**
42
+ * Show a status indicator when modals are being deferred.
43
+ * Default: true
44
+ */
45
+ showStatusIndicator: boolean;
46
+
47
+ /**
48
+ * Custom status text to show when modals are being deferred.
49
+ * Default: "⏸ modal pending — pause to review"
50
+ */
51
+ statusText: string;
52
+ }
53
+
54
+ /**
55
+ * Default configuration values.
56
+ */
57
+ export const DEFAULT_CONFIG: DeferModalConfig = {
58
+ enabled: false,
59
+ modalTypes: ["select", "confirm", "input"],
60
+ quietMs: 1500,
61
+ maxDeferMs: 30_000,
62
+ showStatusIndicator: true,
63
+ statusText: "⏸ modal pending — pause to review",
64
+ };
65
+
66
+ /**
67
+ * Extension ID for this extension.
68
+ */
69
+ export const EXTENSION_ID = "pi-defer-modal";
70
+
71
+ /**
72
+ * Status key for the pending modal indicator.
73
+ */
74
+ export const STATUS_KEY = `${EXTENSION_ID}:modal-pending`;
75
+
76
+ /**
77
+ * Configuration file name.
78
+ */
79
+ export const CONFIG_FILENAME = "config.json";
80
+
81
+ /**
82
+ * Configuration file locations to check, in order of priority.
83
+ * Later entries override earlier ones.
84
+ */
85
+ function getConfigPaths(): string[] {
86
+ const paths: string[] = [];
87
+
88
+ // 1. Project-local: .pi/extensions/pi-defer-modal/config.json
89
+ const projectPath = resolve(process.cwd(), ".pi", "extensions", EXTENSION_ID, CONFIG_FILENAME);
90
+ paths.push(projectPath);
91
+
92
+ // 2. Global: ~/.pi/agent/extensions/pi-defer-modal/config.json
93
+ const home = homedir() || process.env.HOME || "/";
94
+ const globalPath = resolve(home, ".pi", "agent", "extensions", EXTENSION_ID, CONFIG_FILENAME);
95
+ paths.push(globalPath);
96
+
97
+ return paths;
98
+ }
99
+
100
+ /**
101
+ * Load configuration from file, merging with defaults.
102
+ * Later files override earlier ones.
103
+ */
104
+ export function loadConfig(): DeferModalConfig {
105
+ const config: Partial<DeferModalConfig> = {};
106
+ const paths = getConfigPaths();
107
+
108
+ // Load from all available config files, later ones override earlier
109
+ for (const configPath of paths) {
110
+ if (existsSync(configPath)) {
111
+ try {
112
+ const content = readFileSync(configPath, "utf-8");
113
+ const fileConfig = JSON.parse(content) as Partial<DeferModalConfig>;
114
+ // Merge: file config overrides current config
115
+ Object.assign(config, fileConfig);
116
+ console.info(`[${EXTENSION_ID}] Loaded config from ${configPath}`);
117
+ } catch (err) {
118
+ const msg = err instanceof Error ? err.message : String(err);
119
+ console.error(`[${EXTENSION_ID}] Failed to load config from ${configPath}: ${msg}`);
120
+ }
121
+ }
122
+ }
123
+
124
+ // Merge with defaults and return
125
+ return { ...DEFAULT_CONFIG, ...config };
126
+ }
127
+
128
+ /**
129
+ * Create a config reader that can be used by the typing tracker.
130
+ * This allows the config to be refreshed and read consistently.
131
+ */
132
+ export class ConfigStore {
133
+ private config: DeferModalConfig;
134
+
135
+ constructor(initialConfig: Partial<DeferModalConfig> = {}) {
136
+ this.config = { ...DEFAULT_CONFIG, ...initialConfig };
137
+ }
138
+
139
+ /**
140
+ * Get the current configuration.
141
+ */
142
+ current(): DeferModalConfig {
143
+ return { ...this.config };
144
+ }
145
+
146
+ /**
147
+ * Update the configuration.
148
+ */
149
+ update(newConfig: Partial<DeferModalConfig>): void {
150
+ this.config = { ...this.config, ...newConfig };
151
+ }
152
+
153
+ /**
154
+ * Reload configuration from file.
155
+ */
156
+ reload(): void {
157
+ const loadedConfig = loadConfig();
158
+ this.config = loadedConfig;
159
+ }
160
+
161
+ /**
162
+ * Check if a specific modal type should be deferred.
163
+ */
164
+ shouldDeferModalType(modalType: string): boolean {
165
+ const config = this.current();
166
+ if (!config.enabled) return false;
167
+ return config.modalTypes.includes(modalType);
168
+ }
169
+
170
+ /**
171
+ * Get the quiet time in milliseconds.
172
+ */
173
+ getQuietMs(): number {
174
+ return this.current().quietMs;
175
+ }
176
+
177
+ /**
178
+ * Get the maximum defer time in milliseconds.
179
+ */
180
+ getMaxDeferMs(): number {
181
+ return this.current().maxDeferMs;
182
+ }
183
+
184
+ /**
185
+ * Check if status indicator should be shown.
186
+ */
187
+ shouldShowStatus(): boolean {
188
+ return this.current().showStatusIndicator;
189
+ }
190
+
191
+ /**
192
+ * Get the status text to display.
193
+ */
194
+ getStatusText(): string {
195
+ return this.current().statusText;
196
+ }
197
+ }
package/src/index.ts ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * pi-defer-modal extension
3
+ *
4
+ * Defers modal dialogs (select, confirm, input) while the user is actively typing,
5
+ * preventing interruption of the user's workflow. Once the user pauses typing
6
+ * or submits their input, the deferred modals appear.
7
+ *
8
+ * This extension works transparently with any other extension that uses
9
+ * ctx.ui.select(), ctx.ui.confirm(), or ctx.ui.input().
10
+ */
11
+
12
+ import type {
13
+ ExtensionAPI,
14
+ ExtensionContext,
15
+ ExtensionUIDialogOptions,
16
+ } from "@earendil-works/pi-coding-agent";
17
+
18
+ import { ConfigStore, DEFAULT_CONFIG, EXTENSION_ID, loadConfig } from "./config";
19
+ import { TypingTracker } from "./typing-tracker";
20
+
21
+ /**
22
+ * Types for the UI methods we'll wrap.
23
+ */
24
+ type UISelect = (
25
+ title: string,
26
+ options: string[],
27
+ opts?: ExtensionUIDialogOptions,
28
+ ) => Promise<string | undefined>;
29
+ type UIConfirm = (
30
+ title: string,
31
+ message: string,
32
+ opts?: ExtensionUIDialogOptions,
33
+ ) => Promise<boolean>;
34
+ type UIInput = (
35
+ title: string,
36
+ placeholder?: string,
37
+ opts?: ExtensionUIDialogOptions,
38
+ ) => Promise<string | undefined>;
39
+
40
+ /**
41
+ * Original UI methods that we'll wrap and restore.
42
+ */
43
+ interface OriginalUIMethods {
44
+ select?: UISelect;
45
+ confirm?: UIConfirm;
46
+ input?: UIInput;
47
+ }
48
+
49
+ /**
50
+ * Wrapped UI methods that defer modals while typing.
51
+ */
52
+ class DeferredUI {
53
+ private readonly original: OriginalUIMethods;
54
+ private readonly typingTracker: TypingTracker;
55
+ private readonly config: ConfigStore;
56
+
57
+ constructor(
58
+ original: OriginalUIMethods,
59
+ typingTracker: TypingTracker,
60
+ config: ConfigStore,
61
+ ) {
62
+ this.original = original;
63
+ this.typingTracker = typingTracker;
64
+ this.config = config;
65
+ }
66
+
67
+ /**
68
+ * Wrap the select method to defer while typing if configured.
69
+ */
70
+ async select(
71
+ title: string,
72
+ options: string[],
73
+ opts?: ExtensionUIDialogOptions,
74
+ ): Promise<string | undefined> {
75
+ if (this.shouldDefer("select")) {
76
+ await this.typingTracker.waitForQuiet();
77
+ }
78
+ return this.original.select?.(title, options, opts);
79
+ }
80
+
81
+ /**
82
+ * Wrap the confirm method to defer while typing if configured.
83
+ */
84
+ async confirm(
85
+ title: string,
86
+ message: string,
87
+ opts?: ExtensionUIDialogOptions,
88
+ ): Promise<boolean> {
89
+ if (this.shouldDefer("confirm")) {
90
+ await this.typingTracker.waitForQuiet();
91
+ }
92
+ // confirm should always return boolean, not undefined
93
+ return this.original.confirm?.(title, message, opts) ?? false;
94
+ }
95
+
96
+ /**
97
+ * Wrap the input method to defer while typing if configured.
98
+ */
99
+ async input(
100
+ title: string,
101
+ placeholder?: string,
102
+ opts?: ExtensionUIDialogOptions,
103
+ ): Promise<string | undefined> {
104
+ if (this.shouldDefer("input")) {
105
+ await this.typingTracker.waitForQuiet();
106
+ }
107
+ return this.original.input?.(title, placeholder, opts);
108
+ }
109
+
110
+ /**
111
+ * Check if a modal type should be deferred based on configuration.
112
+ */
113
+ private shouldDefer(modalType: string): boolean {
114
+ return this.config.shouldDeferModalType(modalType);
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Main extension function.
120
+ * This extension intercepts UI modal calls and defers them while the user is typing.
121
+ */
122
+ export default function piDeferModalExtension(pi: ExtensionAPI): void {
123
+ // Load configuration from file and create store
124
+ const loadedConfig = loadConfig();
125
+ const config = new ConfigStore(loadedConfig);
126
+
127
+ // Create typing tracker
128
+ const typingTracker = new TypingTracker({ config });
129
+
130
+ // Store original UI methods
131
+ const originalUI: OriginalUIMethods = {};
132
+
133
+ // Flag to track if we've patched the UI
134
+ let isPatched = false;
135
+
136
+ // Patch the UI methods on session start
137
+ pi.on("session_start", (event, ctx) => {
138
+ if (isPatched) {
139
+ // Already patched - just update the tracker context
140
+ typingTracker.start(ctx);
141
+ return;
142
+ }
143
+
144
+ // Store original methods - we need to preserve the 'this' context
145
+ originalUI.select = ctx.ui.select?.bind(ctx.ui);
146
+ originalUI.confirm = ctx.ui.confirm?.bind(ctx.ui);
147
+ originalUI.input = ctx.ui.input?.bind(ctx.ui);
148
+
149
+ // Create deferred UI wrapper
150
+ const deferredUI = new DeferredUI(originalUI, typingTracker, config);
151
+
152
+ // Replace the UI methods with our wrapped versions
153
+ ctx.ui.select = deferredUI.select.bind(deferredUI);
154
+ ctx.ui.confirm = deferredUI.confirm.bind(deferredUI);
155
+ ctx.ui.input = deferredUI.input.bind(deferredUI);
156
+
157
+ isPatched = true;
158
+ typingTracker.start(ctx);
159
+
160
+ console.info(`[${EXTENSION_ID}] Modal deferral extension activated`);
161
+ });
162
+
163
+ // Clean up on session shutdown
164
+ pi.on("session_shutdown", () => {
165
+ typingTracker.stop();
166
+ isPatched = false;
167
+ console.info(`[${EXTENSION_ID}] Modal deferral extension deactivated`);
168
+ });
169
+
170
+ // Notify the tracker when user submits input
171
+ pi.on("input", (event, ctx) => {
172
+ typingTracker.notifySubmit();
173
+ });
174
+
175
+ // For now, we'll use a simple configuration approach.
176
+ // In a production extension, you might want to:
177
+ // 1. Load config from a config file
178
+ // 2. Provide a command to update config
179
+ // 3. Watch for config file changes
180
+
181
+ // Register a command to toggle the extension
182
+ pi.registerCommand("defer-modal-toggle", {
183
+ description: "Toggle modal deferral on/off",
184
+ handler: async (args: string, ctx: ExtensionContext) => {
185
+ const currentConfig = config.current();
186
+ config.update({ enabled: !currentConfig.enabled });
187
+ const newConfig = config.current();
188
+ console.info(
189
+ `[${EXTENSION_ID}] Modal deferral ${newConfig.enabled ? "enabled" : "disabled"}`,
190
+ );
191
+ await ctx.ui.notify(
192
+ `Modal deferral is now ${newConfig.enabled ? "enabled" : "disabled"}.`
193
+ );
194
+ },
195
+ });
196
+
197
+ // Register a command to show current config
198
+ pi.registerCommand("defer-modal-config", {
199
+ description: "Show current modal deferral configuration",
200
+ handler: async (args: string, ctx: ExtensionContext) => {
201
+ const currentConfig = config.current();
202
+ await ctx.ui.notify(
203
+ `Modal deferral config:\n` +
204
+ ` Enabled: ${currentConfig.enabled}\n` +
205
+ ` Modal types: ${currentConfig.modalTypes.join(", ")}\n` +
206
+ ` Quiet time: ${currentConfig.quietMs}ms\n` +
207
+ ` Max defer: ${currentConfig.maxDeferMs}ms\n` +
208
+ ` Show status: ${currentConfig.showStatusIndicator}\n` +
209
+ ` Status text: "${currentConfig.statusText}"`
210
+ );
211
+ },
212
+ });
213
+
214
+ // Register a command to reload config from file
215
+ pi.registerCommand("defer-modal-reload", {
216
+ description: "Reload modal deferral configuration from file",
217
+ handler: async (args: string, ctx: ExtensionContext) => {
218
+ config.reload();
219
+ const currentConfig = config.current();
220
+ console.info(`[${EXTENSION_ID}] Configuration reloaded from file`);
221
+ await ctx.ui.notify(
222
+ `Configuration reloaded:\n` +
223
+ ` Enabled: ${currentConfig.enabled}\n` +
224
+ ` Quiet time: ${currentConfig.quietMs}ms`
225
+ );
226
+ },
227
+ });
228
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * TypingTracker - tracks user typing activity to defer modals appropriately.
3
+ *
4
+ * This is a generic version that can be used to defer any modal dialog,
5
+ * not just permission prompts.
6
+ */
7
+
8
+ import type {
9
+ ExtensionContext,
10
+ ExtensionUIContext,
11
+ TerminalInputHandler,
12
+ } from "@earendil-works/pi-coding-agent";
13
+
14
+ import { ConfigStore, STATUS_KEY } from "./config";
15
+
16
+ /**
17
+ * Poll granularity (ms) for the debounce loop.
18
+ */
19
+ const POLL_INTERVAL_MS = 50;
20
+
21
+ /**
22
+ * Narrow UI surface the tracker needs; the real `ctx.ui` satisfies it.
23
+ */
24
+ type TrackerUi = Partial<
25
+ Pick<ExtensionUIContext, "onTerminalInput" | "getEditorText" | "setStatus">
26
+ >;
27
+
28
+ /**
29
+ * Narrow ctx surface the tracker needs; the real `ctx` satisfies it.
30
+ */
31
+ type TrackerCtx = Pick<ExtensionContext, "mode"> & { ui: TrackerUi };
32
+
33
+ /**
34
+ * Dependencies for the TypingTracker.
35
+ */
36
+ export interface TypingTrackerDeps {
37
+ config: ConfigStore;
38
+ /** Clock source; injectable for deterministic tests. Defaults to `Date.now`. */
39
+ now?: () => number;
40
+ /** Sleep; injectable for deterministic tests. Defaults to a `setTimeout` wait. */
41
+ sleep?: (ms: number) => Promise<void>;
42
+ }
43
+
44
+ /**
45
+ * Tracks recent keyboard activity for one session and gates modal display
46
+ * behind a "wait for the user to pause" debounce.
47
+ *
48
+ * Lifecycle: `start(ctx)` on session start (idempotent — re-entry drops the prior
49
+ * subscription, since `session_start` also fires on reload), `stop()` on shutdown.
50
+ * `notifySubmit()` is called from the `input` handler so submitting resolves any
51
+ * in-flight wait immediately.
52
+ */
53
+ export class TypingTracker {
54
+ private readonly config: ConfigStore;
55
+ private readonly now: () => number;
56
+ private readonly sleep: (ms: number) => Promise<void>;
57
+ private ctx: TrackerCtx | null = null;
58
+ private unsubscribe: (() => void) | null = null;
59
+ private lastInputAt = 0;
60
+ private submitted = false;
61
+
62
+ constructor(deps: TypingTrackerDeps) {
63
+ this.config = deps.config;
64
+ this.now = deps.now ?? Date.now;
65
+ this.sleep =
66
+ deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
67
+ }
68
+
69
+ /**
70
+ * Subscribe to terminal input for `ctx`. Idempotent: drops any prior
71
+ * subscription first. Only subscribes in the interactive TUI; in other modes
72
+ * the ctx is stored but `waitForQuiet()` short-circuits.
73
+ */
74
+ start(ctx: TrackerCtx): void {
75
+ this.stop();
76
+ this.ctx = ctx;
77
+ if (ctx.mode !== "tui") return;
78
+ const onTerminalInput = ctx.ui.onTerminalInput;
79
+ if (typeof onTerminalInput !== "function") return;
80
+ const handler: TerminalInputHandler = () => {
81
+ this.lastInputAt = this.now();
82
+ return undefined; // observe only — never consume the keystroke
83
+ };
84
+ this.unsubscribe = onTerminalInput.call(ctx.ui, handler);
85
+ }
86
+
87
+ /**
88
+ * Unsubscribe from terminal input and clear the stored ctx.
89
+ */
90
+ stop(): void {
91
+ if (this.unsubscribe) {
92
+ this.unsubscribe();
93
+ this.unsubscribe = null;
94
+ }
95
+ this.ctx = null;
96
+ }
97
+
98
+ /**
99
+ * Resolve any in-flight wait immediately — the user submitted their input.
100
+ */
101
+ notifySubmit(): void {
102
+ this.submitted = true;
103
+ }
104
+
105
+ /**
106
+ * Resolve once the user has paused typing (or submitted), so a deferred modal
107
+ * does not interrupt active composition.
108
+ *
109
+ * Resolves immediately when deferral is disabled, outside the TUI, when the
110
+ * editor is empty (nothing to protect), or when typing already stopped longer
111
+ * ago than the quiet gap.
112
+ */
113
+ async waitForQuiet(): Promise<void> {
114
+ const config = this.config.current();
115
+ if (!config.enabled) return;
116
+
117
+ const ctx = this.ctx;
118
+ if (ctx?.mode !== "tui") return;
119
+ if (this.isEditorEmpty(ctx)) return;
120
+
121
+ const quietMs = config.quietMs;
122
+ if (this.now() - this.lastInputAt >= quietMs) return;
123
+
124
+ this.takeSubmitted(); // clear any stale submit from a prior wait
125
+ const startedAt = this.now();
126
+
127
+ if (config.showStatusIndicator) {
128
+ this.setPendingStatus(ctx, config.statusText);
129
+ }
130
+
131
+ try {
132
+ for (;;) {
133
+ if (this.takeSubmitted()) return;
134
+ if (this.isEditorEmpty(ctx)) return;
135
+ const sinceInput = this.now() - this.lastInputAt;
136
+ if (sinceInput >= quietMs) return;
137
+ if (this.now() - startedAt >= config.maxDeferMs) return;
138
+ await this.sleep(Math.min(quietMs - sinceInput, POLL_INTERVAL_MS));
139
+ }
140
+ } finally {
141
+ this.setPendingStatus(ctx, undefined);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Read and clear the submit flag in one step.
147
+ */
148
+ private takeSubmitted(): boolean {
149
+ const submitted = this.submitted;
150
+ this.submitted = false;
151
+ return submitted;
152
+ }
153
+
154
+ private isEditorEmpty(ctx: TrackerCtx): boolean {
155
+ const getEditorText = ctx.ui.getEditorText;
156
+ if (typeof getEditorText !== "function") return false;
157
+ return getEditorText.call(ctx.ui).trim().length === 0;
158
+ }
159
+
160
+ private setPendingStatus(ctx: TrackerCtx, text: string | undefined): void {
161
+ ctx.ui.setStatus?.(STATUS_KEY, text);
162
+ }
163
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022"],
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "resolveJsonModule": true,
12
+ "declaration": true,
13
+ "declarationMap": true,
14
+ "sourceMap": true,
15
+ "outDir": "dist",
16
+ "rootDir": "src"
17
+ },
18
+ "include": ["src/**/*"],
19
+ "exclude": ["node_modules", "dist"]
20
+ }