@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 +137 -0
- package/package.json +32 -0
- package/src/config.ts +197 -0
- package/src/index.ts +228 -0
- package/src/typing-tracker.ts +163 -0
- package/tsconfig.json +20 -0
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
|
+
}
|