pi-one-ui 0.7.0 → 0.7.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/CHANGELOG.md +25 -1
- package/README.en.md +19 -6
- package/README.md +19 -6
- package/extensions/app/config/context.ts +42 -0
- package/extensions/app/config/editor.ts +80 -0
- package/extensions/app/config/footer.ts +291 -0
- package/extensions/app/config/panel.ts +1 -5
- package/extensions/app/config/renderer.ts +1 -6
- package/extensions/app/config/shell.ts +358 -1435
- package/extensions/app/config/store.ts +22 -1
- package/extensions/app/config/values.ts +33 -0
- package/extensions/app/config/working-line.ts +100 -0
- package/extensions/app/overlay/input-router.ts +56 -32
- package/extensions/app/overlay/overlay-manager.ts +183 -21
- package/extensions/app/runtime/event-coordinator.ts +90 -42
- package/extensions/{features → app/runtime}/flush-docked-bash.ts +12 -2
- package/extensions/app/runtime/render-scheduler.ts +35 -31
- package/extensions/app/runtime/tui-runtime.ts +315 -312
- package/extensions/app/settings/controller.ts +202 -0
- package/extensions/app/settings/panel.ts +81 -0
- package/extensions/app/settings/presets.ts +43 -0
- package/extensions/features/context-inspector/index.ts +83 -662
- package/extensions/layouts/context/index.ts +58 -161
- package/extensions/layouts/context/message/user-message.ts +1 -1
- package/extensions/layouts/context/renderer/index.ts +31 -13
- package/extensions/layouts/context/renderer/mouse/interaction.ts +29 -14
- package/extensions/layouts/editor/controller.ts +9 -18
- package/extensions/layouts/editor/factory.ts +3 -3
- package/extensions/layouts/footer/controller.ts +30 -19
- package/extensions/layouts/footer/footer.ts +2 -2
- package/extensions/layouts/overlay/context-inspector.ts +497 -0
- package/extensions/{app → layouts}/overlay/selector-border.ts +5 -5
- package/extensions/{app → layouts}/overlay/selector-controller.ts +1 -1
- package/extensions/layouts/overlay/settings-panel.ts +359 -0
- package/extensions/{app/commands → layouts/overlay}/settings-previews.ts +4 -4
- package/extensions/layouts/working-line/controller.ts +3 -29
- package/extensions/layouts/working-line/working-line.ts +17 -114
- package/extensions/shared/working-line-text.ts +108 -0
- package/package.json +1 -1
- package/extensions/app/host/pi-extension-port.ts +0 -25
- package/extensions/app/host/pi-ui-port.ts +0 -52
- package/extensions/app/host/tui-capabilities.ts +0 -27
- package/extensions/app/ownership/layout-registry.ts +0 -65
- package/extensions/app/ownership.ts +0 -32
- package/extensions/app/panel.ts +0 -770
- package/extensions/app/presets.ts +0 -35
- package/extensions/app/runtime/runtime-state.ts +0 -83
- /package/extensions/{layouts/working-line → app/config}/working-line-messages.ts +0 -0
- /package/extensions/app/{ownership → runtime}/prototype-patch-registry.ts +0 -0
|
@@ -173,6 +173,17 @@ export function mutateConfigFile(
|
|
|
173
173
|
|
|
174
174
|
export type ConfigStoreListener = (record: ConfigRecord) => void;
|
|
175
175
|
|
|
176
|
+
/** 文件已经提交;调用方可以显示已保存的配置及应用错误。 */
|
|
177
|
+
export class ConfigApplicationError extends AggregateError {
|
|
178
|
+
constructor(
|
|
179
|
+
readonly record: ConfigRecord,
|
|
180
|
+
errors: unknown[],
|
|
181
|
+
) {
|
|
182
|
+
super(errors, "Configuration was saved but could not be applied");
|
|
183
|
+
this.name = "ConfigApplicationError";
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
176
187
|
/**
|
|
177
188
|
* Process-wide raw configuration store. Domain config modules own parsing and
|
|
178
189
|
* selectors; this module owns canonical persistence.
|
|
@@ -205,7 +216,17 @@ export class ConfigStore {
|
|
|
205
216
|
state.record,
|
|
206
217
|
state.kind === "valid" ? state.mode : undefined,
|
|
207
218
|
);
|
|
208
|
-
|
|
219
|
+
const errors: unknown[] = [];
|
|
220
|
+
for (const listener of [...this.listeners]) {
|
|
221
|
+
try {
|
|
222
|
+
listener(state.record);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
errors.push(error);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (errors.length > 0) {
|
|
228
|
+
throw new ConfigApplicationError(state.record, errors);
|
|
229
|
+
}
|
|
209
230
|
return state.record;
|
|
210
231
|
}
|
|
211
232
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ConfigRecord } from "./store.ts";
|
|
2
|
+
|
|
3
|
+
export function isRecord(value: unknown): value is ConfigRecord {
|
|
4
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function recordValue(value: unknown): ConfigRecord {
|
|
8
|
+
return isRecord(value) ? value : {};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function booleanValue(value: unknown, defaultValue: boolean): boolean {
|
|
12
|
+
return typeof value === "boolean" ? value : defaultValue;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 更新已知字段,同时保留配置中其他字段及其属性名称。 */
|
|
16
|
+
export function overlayKnown(raw: unknown, known: unknown): unknown {
|
|
17
|
+
if (known === undefined) {
|
|
18
|
+
return raw;
|
|
19
|
+
}
|
|
20
|
+
if (!isRecord(known)) {
|
|
21
|
+
return known;
|
|
22
|
+
}
|
|
23
|
+
const output: ConfigRecord = { ...recordValue(raw) };
|
|
24
|
+
for (const [key, value] of Object.entries(known)) {
|
|
25
|
+
Object.defineProperty(output, key, {
|
|
26
|
+
value: overlayKnown(output[key], value),
|
|
27
|
+
enumerable: true,
|
|
28
|
+
configurable: true,
|
|
29
|
+
writable: true,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return output;
|
|
33
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { normalizeWorkingLineMessages } from "../../shared/working-line-text.ts";
|
|
2
|
+
import { booleanValue, recordValue } from "./values.ts";
|
|
3
|
+
import { PI_WORKING_LINE_MESSAGES } from "./working-line-messages.ts";
|
|
4
|
+
|
|
5
|
+
export type WorkingLineSpinner =
|
|
6
|
+
| "braille"
|
|
7
|
+
| "star-bloom"
|
|
8
|
+
| "pinwheel"
|
|
9
|
+
| "claude-inspired"
|
|
10
|
+
| "pulse";
|
|
11
|
+
export type WorkingLineTextAnimation = "classic" | "kitt" | "disabled";
|
|
12
|
+
export type WorkingLineMessagesConfig = { custom: boolean; values: string[] };
|
|
13
|
+
export type WorkingLineSegmentsConfig = {
|
|
14
|
+
tool: boolean;
|
|
15
|
+
elapsed: boolean;
|
|
16
|
+
thought: boolean;
|
|
17
|
+
tokens: boolean;
|
|
18
|
+
};
|
|
19
|
+
export type WorkingLineComponentConfig = {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
turnSummary: boolean;
|
|
22
|
+
spinner: WorkingLineSpinner;
|
|
23
|
+
spinnerIntervalMs: number;
|
|
24
|
+
animateSpinnerColor: boolean;
|
|
25
|
+
textIntervalMs: number;
|
|
26
|
+
textAnimation: WorkingLineTextAnimation;
|
|
27
|
+
messages: WorkingLineMessagesConfig;
|
|
28
|
+
segments: WorkingLineSegmentsConfig;
|
|
29
|
+
};
|
|
30
|
+
export type WorkingLineComponentPatch = Partial<
|
|
31
|
+
Omit<WorkingLineComponentConfig, "messages" | "segments">
|
|
32
|
+
> & {
|
|
33
|
+
messages?: Partial<WorkingLineMessagesConfig>;
|
|
34
|
+
segments?: Partial<WorkingLineSegmentsConfig>;
|
|
35
|
+
};
|
|
36
|
+
export const DEFAULT_WORKING_LINE_SPINNER_INTERVAL_MS = 100;
|
|
37
|
+
export const DEFAULT_WORKING_LINE_TEXT_INTERVAL_MS = 60;
|
|
38
|
+
export const MIN_WORKING_LINE_INTERVAL_MS = 30;
|
|
39
|
+
export const MAX_WORKING_LINE_INTERVAL_MS = 1000;
|
|
40
|
+
export const defaultWorkingLine: WorkingLineComponentConfig = {
|
|
41
|
+
enabled: false,
|
|
42
|
+
turnSummary: true,
|
|
43
|
+
spinner: "star-bloom",
|
|
44
|
+
spinnerIntervalMs: DEFAULT_WORKING_LINE_SPINNER_INTERVAL_MS,
|
|
45
|
+
animateSpinnerColor: false,
|
|
46
|
+
textIntervalMs: DEFAULT_WORKING_LINE_TEXT_INTERVAL_MS,
|
|
47
|
+
textAnimation: "classic",
|
|
48
|
+
messages: { custom: true, values: [...PI_WORKING_LINE_MESSAGES] },
|
|
49
|
+
segments: { tool: true, elapsed: true, thought: true, tokens: true },
|
|
50
|
+
};
|
|
51
|
+
export function isValidWorkingLineIntervalMs(value: unknown): value is number {
|
|
52
|
+
return (
|
|
53
|
+
typeof value === "number" &&
|
|
54
|
+
Number.isSafeInteger(value) &&
|
|
55
|
+
value >= MIN_WORKING_LINE_INTERVAL_MS &&
|
|
56
|
+
value <= MAX_WORKING_LINE_INTERVAL_MS
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function normalizeWorkingLine(
|
|
61
|
+
value: unknown,
|
|
62
|
+
): WorkingLineComponentConfig {
|
|
63
|
+
const source = recordValue(value);
|
|
64
|
+
const messages = recordValue(source.messages);
|
|
65
|
+
const segments = recordValue(source.segments);
|
|
66
|
+
return {
|
|
67
|
+
enabled: booleanValue(source.enabled, false),
|
|
68
|
+
turnSummary: booleanValue(source.turnSummary, true),
|
|
69
|
+
spinner:
|
|
70
|
+
source.spinner === "braille" ||
|
|
71
|
+
source.spinner === "pinwheel" ||
|
|
72
|
+
source.spinner === "claude-inspired" ||
|
|
73
|
+
source.spinner === "pulse"
|
|
74
|
+
? source.spinner
|
|
75
|
+
: "star-bloom",
|
|
76
|
+
spinnerIntervalMs: isValidWorkingLineIntervalMs(source.spinnerIntervalMs)
|
|
77
|
+
? source.spinnerIntervalMs
|
|
78
|
+
: DEFAULT_WORKING_LINE_SPINNER_INTERVAL_MS,
|
|
79
|
+
animateSpinnerColor: booleanValue(source.animateSpinnerColor, false),
|
|
80
|
+
textIntervalMs: isValidWorkingLineIntervalMs(source.textIntervalMs)
|
|
81
|
+
? source.textIntervalMs
|
|
82
|
+
: DEFAULT_WORKING_LINE_TEXT_INTERVAL_MS,
|
|
83
|
+
textAnimation:
|
|
84
|
+
source.textAnimation === "kitt" || source.textAnimation === "disabled"
|
|
85
|
+
? source.textAnimation
|
|
86
|
+
: "classic",
|
|
87
|
+
messages: {
|
|
88
|
+
custom: booleanValue(messages.custom, true),
|
|
89
|
+
values: Object.hasOwn(messages, "values")
|
|
90
|
+
? normalizeWorkingLineMessages(messages.values)
|
|
91
|
+
: [...PI_WORKING_LINE_MESSAGES],
|
|
92
|
+
},
|
|
93
|
+
segments: {
|
|
94
|
+
tool: booleanValue(segments.tool, true),
|
|
95
|
+
elapsed: booleanValue(segments.elapsed, true),
|
|
96
|
+
thought: booleanValue(segments.thought, true),
|
|
97
|
+
tokens: booleanValue(segments.tokens, true),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -1,32 +1,22 @@
|
|
|
1
|
+
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
1
3
|
export type InputRouteResult = { consume?: boolean; data?: string } | undefined;
|
|
2
4
|
export type InputRoute = (data: string) => InputRouteResult;
|
|
5
|
+
type InputHost = Pick<ExtensionUIContext, "onTerminalInput">;
|
|
6
|
+
type RegisteredRoute = { priority: number; order: number; route: InputRoute };
|
|
7
|
+
type HostRegistration = { remove: () => void; leases: Set<object> };
|
|
3
8
|
|
|
4
|
-
|
|
5
|
-
priority: number;
|
|
6
|
-
order: number;
|
|
7
|
-
route: InputRoute;
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Routes raw terminal input by descending priority and stops at a consumer.
|
|
12
|
-
*/
|
|
9
|
+
/** 输入路由与 Pi listener 共同注册、共同释放。 */
|
|
13
10
|
export class InputRouter {
|
|
14
11
|
private nextOrder = 0;
|
|
15
12
|
private readonly routes: RegisteredRoute[] = [];
|
|
13
|
+
private readonly hosts = new Map<
|
|
14
|
+
InputHost["onTerminalInput"],
|
|
15
|
+
HostRegistration
|
|
16
|
+
>();
|
|
16
17
|
|
|
17
|
-
/**
|
|
18
|
-
* Registers an input route.
|
|
19
|
-
*
|
|
20
|
-
* @param route Handler that may consume one input packet.
|
|
21
|
-
* @param priority Higher-priority routes receive input first.
|
|
22
|
-
* @returns A function that removes the route.
|
|
23
|
-
*/
|
|
24
18
|
register(route: InputRoute, priority = 0): () => void {
|
|
25
|
-
const entry:
|
|
26
|
-
priority,
|
|
27
|
-
order: this.nextOrder++,
|
|
28
|
-
route,
|
|
29
|
-
};
|
|
19
|
+
const entry = { priority, order: this.nextOrder++, route };
|
|
30
20
|
this.routes.push(entry);
|
|
31
21
|
this.routes.sort(
|
|
32
22
|
(left, right) =>
|
|
@@ -34,29 +24,63 @@ export class InputRouter {
|
|
|
34
24
|
);
|
|
35
25
|
return () => {
|
|
36
26
|
const index = this.routes.indexOf(entry);
|
|
37
|
-
if (index >= 0)
|
|
27
|
+
if (index >= 0) {
|
|
28
|
+
this.routes.splice(index, 1);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
bind(host: InputHost, route: InputRoute, priority = 0): () => void {
|
|
34
|
+
const removeRoute = this.register(route, priority);
|
|
35
|
+
const key = host.onTerminalInput;
|
|
36
|
+
let registration = this.hosts.get(key);
|
|
37
|
+
try {
|
|
38
|
+
if (!registration) {
|
|
39
|
+
registration = {
|
|
40
|
+
remove: host.onTerminalInput((data) => this.dispatch(data)),
|
|
41
|
+
leases: new Set(),
|
|
42
|
+
};
|
|
43
|
+
this.hosts.set(key, registration);
|
|
44
|
+
}
|
|
45
|
+
} catch (error) {
|
|
46
|
+
removeRoute();
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
const current = registration;
|
|
50
|
+
const token = {};
|
|
51
|
+
current.leases.add(token);
|
|
52
|
+
return () => {
|
|
53
|
+
removeRoute();
|
|
54
|
+
if (
|
|
55
|
+
!current.leases.delete(token) ||
|
|
56
|
+
current.leases.size > 0 ||
|
|
57
|
+
this.hosts.get(key) !== current
|
|
58
|
+
) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
this.hosts.delete(key);
|
|
62
|
+
current.remove();
|
|
38
63
|
};
|
|
39
64
|
}
|
|
40
65
|
|
|
41
|
-
/**
|
|
42
|
-
* Routes one input packet until a registered handler consumes it.
|
|
43
|
-
*
|
|
44
|
-
* @param data Raw terminal input packet.
|
|
45
|
-
* @returns The consuming route result, if any.
|
|
46
|
-
*/
|
|
47
66
|
dispatch(data: string): InputRouteResult {
|
|
48
67
|
for (const entry of [...this.routes]) {
|
|
49
68
|
const result = entry.route(data);
|
|
50
|
-
if (result?.consume)
|
|
69
|
+
if (result?.consume) {
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
51
72
|
}
|
|
52
73
|
return undefined;
|
|
53
74
|
}
|
|
54
75
|
|
|
55
|
-
/**
|
|
56
|
-
* Removes all registered routes, normally during runtime teardown.
|
|
57
|
-
*/
|
|
58
76
|
clear(): void {
|
|
59
77
|
this.routes.length = 0;
|
|
78
|
+
const registrations = [...this.hosts.values()];
|
|
79
|
+
this.hosts.clear();
|
|
80
|
+
for (const registration of registrations) {
|
|
81
|
+
registration.leases.clear();
|
|
82
|
+
registration.remove();
|
|
83
|
+
}
|
|
60
84
|
}
|
|
61
85
|
}
|
|
62
86
|
|
|
@@ -1,38 +1,200 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionContext,
|
|
3
|
+
ExtensionUIContext,
|
|
4
|
+
KeybindingsManager,
|
|
5
|
+
Theme,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type {
|
|
8
|
+
Component,
|
|
9
|
+
OverlayHandle,
|
|
10
|
+
OverlayOptions,
|
|
11
|
+
TUI,
|
|
12
|
+
} from "@earendil-works/pi-tui";
|
|
13
|
+
|
|
1
14
|
export type OverlayTask<T> = () => Promise<T>;
|
|
15
|
+
export type OverlayFactory<T> = (
|
|
16
|
+
tui: TUI,
|
|
17
|
+
theme: Theme,
|
|
18
|
+
keybindings: KeybindingsManager,
|
|
19
|
+
done: (result: T | undefined) => void,
|
|
20
|
+
) =>
|
|
21
|
+
| (Component & { dispose?(): void })
|
|
22
|
+
| Promise<Component & { dispose?(): void }>;
|
|
23
|
+
export type ManagedOverlayOptions = {
|
|
24
|
+
overlayOptions?: OverlayOptions | (() => OverlayOptions);
|
|
25
|
+
preserveEditorFocus?: boolean;
|
|
26
|
+
onHandle?: (handle: OverlayHandle) => void;
|
|
27
|
+
};
|
|
28
|
+
type FocusTui = TUI & {
|
|
29
|
+
getFocusedComponent?: () => Component | null;
|
|
30
|
+
isOverlayFocused?: () => boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** 配置更新可能替换 Editor;只恢复本面板仍然拥有的返回目标。 */
|
|
34
|
+
class EditorFocusCoordinator {
|
|
35
|
+
private readonly originalFocus: Component | null | undefined;
|
|
36
|
+
private editorFocus:
|
|
37
|
+
| {
|
|
38
|
+
component: Component;
|
|
39
|
+
factory: ReturnType<ExtensionUIContext["getEditorComponent"]>;
|
|
40
|
+
}
|
|
41
|
+
| undefined;
|
|
42
|
+
handle: OverlayHandle | undefined;
|
|
43
|
+
|
|
44
|
+
constructor(
|
|
45
|
+
private readonly tui: FocusTui,
|
|
46
|
+
private readonly ui: ExtensionUIContext,
|
|
47
|
+
private readonly active: () => boolean,
|
|
48
|
+
) {
|
|
49
|
+
this.originalFocus = tui.getFocusedComponent?.();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
change(action: () => void): void {
|
|
53
|
+
if (this.originalFocus !== undefined) {
|
|
54
|
+
this.handle?.unfocus({
|
|
55
|
+
target: this.editorFocus?.component ?? this.originalFocus,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const previous = this.tui.getFocusedComponent?.();
|
|
59
|
+
try {
|
|
60
|
+
action();
|
|
61
|
+
} finally {
|
|
62
|
+
const next = this.tui.getFocusedComponent?.();
|
|
63
|
+
if (next && next !== previous && !this.tui.isOverlayFocused?.()) {
|
|
64
|
+
this.editorFocus = {
|
|
65
|
+
component: next,
|
|
66
|
+
factory: this.ui.getEditorComponent(),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (this.active()) {
|
|
70
|
+
this.handle?.focus();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
close(done: () => void): void {
|
|
76
|
+
done();
|
|
77
|
+
if (
|
|
78
|
+
!this.active() ||
|
|
79
|
+
!this.editorFocus ||
|
|
80
|
+
this.tui.hasOverlay() ||
|
|
81
|
+
this.tui.getFocusedComponent?.() !== this.originalFocus ||
|
|
82
|
+
this.ui.getEditorComponent() !== this.editorFocus.factory
|
|
83
|
+
) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.tui.setFocus(this.editorFocus.component);
|
|
87
|
+
this.tui.requestRender();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
2
90
|
|
|
3
|
-
/**
|
|
4
|
-
* Tracks plugin-owned overlay activity across settings and context dialogs.
|
|
5
|
-
*/
|
|
91
|
+
/** 管理本扩展的 Overlay、取消操作和跨组件焦点协作。 */
|
|
6
92
|
export class OverlayManager {
|
|
7
|
-
private
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
* @returns The value produced when the overlay closes.
|
|
14
|
-
*/
|
|
93
|
+
private session = {};
|
|
94
|
+
private active = true;
|
|
95
|
+
private readonly tasks = new Set<object>();
|
|
96
|
+
private readonly closers = new Map<object, () => void>();
|
|
97
|
+
private readonly editorFocus = new Map<object, EditorFocusCoordinator>();
|
|
98
|
+
|
|
15
99
|
async run<T>(task: OverlayTask<T>): Promise<T> {
|
|
16
|
-
|
|
100
|
+
const token = {};
|
|
101
|
+
this.tasks.add(token);
|
|
17
102
|
try {
|
|
18
103
|
return await task();
|
|
19
104
|
} finally {
|
|
20
|
-
this.
|
|
105
|
+
this.tasks.delete(token);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async open<T = void>(
|
|
110
|
+
ctx: Pick<ExtensionContext, "ui">,
|
|
111
|
+
factory: OverlayFactory<T>,
|
|
112
|
+
options: ManagedOverlayOptions = {},
|
|
113
|
+
): Promise<T | undefined> {
|
|
114
|
+
if (!this.active) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
const token = {};
|
|
118
|
+
this.tasks.add(token);
|
|
119
|
+
let focus: EditorFocusCoordinator | undefined;
|
|
120
|
+
try {
|
|
121
|
+
return await ctx.ui.custom<T | undefined>(
|
|
122
|
+
(tui, theme, keybindings, done) => {
|
|
123
|
+
if (options.preserveEditorFocus) {
|
|
124
|
+
focus = new EditorFocusCoordinator(tui, ctx.ui, () =>
|
|
125
|
+
this.tasks.has(token),
|
|
126
|
+
);
|
|
127
|
+
this.editorFocus.set(token, focus);
|
|
128
|
+
}
|
|
129
|
+
let closed = false;
|
|
130
|
+
const complete = (result: T | undefined): void => {
|
|
131
|
+
if (closed) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
closed = true;
|
|
135
|
+
if (focus) {
|
|
136
|
+
focus.close(() => done(result));
|
|
137
|
+
} else {
|
|
138
|
+
done(result);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
this.closers.set(token, () => complete(undefined));
|
|
142
|
+
return factory(tui, theme, keybindings, complete);
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
overlay: true,
|
|
146
|
+
overlayOptions: options.overlayOptions,
|
|
147
|
+
onHandle: (handle) => {
|
|
148
|
+
if (focus) {
|
|
149
|
+
focus.handle = handle;
|
|
150
|
+
}
|
|
151
|
+
options.onHandle?.(handle);
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
} finally {
|
|
156
|
+
this.tasks.delete(token);
|
|
157
|
+
this.closers.delete(token);
|
|
158
|
+
this.editorFocus.delete(token);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
updateEditor(action: () => void): void {
|
|
163
|
+
const focus = [...this.editorFocus.values()].at(-1);
|
|
164
|
+
if (focus) {
|
|
165
|
+
focus.change(action);
|
|
166
|
+
} else {
|
|
167
|
+
action();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
sessionGuard(): () => boolean {
|
|
172
|
+
const session = this.session;
|
|
173
|
+
return () => this.active && this.session === session;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
startSession(): void {
|
|
177
|
+
this.reset();
|
|
178
|
+
this.active = true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
reset(): void {
|
|
182
|
+
this.active = false;
|
|
183
|
+
this.session = {};
|
|
184
|
+
this.tasks.clear();
|
|
185
|
+
for (const close of [...this.closers.values()]) {
|
|
186
|
+
close();
|
|
21
187
|
}
|
|
188
|
+
this.closers.clear();
|
|
189
|
+
this.editorFocus.clear();
|
|
22
190
|
}
|
|
23
191
|
|
|
24
|
-
/**
|
|
25
|
-
* Reports whether at least one plugin-owned overlay is active.
|
|
26
|
-
*/
|
|
27
192
|
hasActive(): boolean {
|
|
28
|
-
return this.
|
|
193
|
+
return this.tasks.size > 0;
|
|
29
194
|
}
|
|
30
195
|
|
|
31
|
-
/**
|
|
32
|
-
* Returns the current plugin-owned overlay depth.
|
|
33
|
-
*/
|
|
34
196
|
depth(): number {
|
|
35
|
-
return this.
|
|
197
|
+
return this.tasks.size;
|
|
36
198
|
}
|
|
37
199
|
}
|
|
38
200
|
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
ExtensionEvent,
|
|
5
|
+
MessageEndEvent,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
2
7
|
|
|
3
8
|
export const RUNTIME_EVENTS = [
|
|
4
9
|
"session_start",
|
|
@@ -17,68 +22,111 @@ export const RUNTIME_EVENTS = [
|
|
|
17
22
|
"session_compact",
|
|
18
23
|
"session_tree",
|
|
19
24
|
] as const;
|
|
20
|
-
|
|
21
25
|
export type RuntimeEventName = (typeof RUNTIME_EVENTS)[number];
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
type RuntimeEvent<K extends RuntimeEventName> = Extract<
|
|
27
|
+
ExtensionEvent,
|
|
28
|
+
{ type: K }
|
|
29
|
+
>;
|
|
30
|
+
type MessageReplacement = { message: MessageEndEvent["message"] };
|
|
31
|
+
export type RuntimeEventHandler<K extends RuntimeEventName = RuntimeEventName> =
|
|
32
|
+
(
|
|
33
|
+
event: RuntimeEvent<K>,
|
|
34
|
+
ctx: ExtensionContext,
|
|
35
|
+
) => void | MessageReplacement | Promise<void | MessageReplacement>;
|
|
27
36
|
export type EventRegistrar = {
|
|
28
37
|
on(event: RuntimeEventName, handler: RuntimeEventHandler): void;
|
|
29
38
|
};
|
|
30
39
|
|
|
31
|
-
/**
|
|
32
|
-
* Provides the central host event seam used while legacy listeners migrate.
|
|
33
|
-
*/
|
|
40
|
+
/** 共享事件保留注册顺序、同步执行和 message_end 的替换语义。 */
|
|
34
41
|
export class EventCoordinator {
|
|
35
|
-
private readonly registrar: EventRegistrar;
|
|
36
42
|
private readonly handlers = new Map<
|
|
37
43
|
RuntimeEventName,
|
|
38
44
|
Set<RuntimeEventHandler>
|
|
39
45
|
>();
|
|
40
46
|
private installed = false;
|
|
41
47
|
|
|
42
|
-
|
|
43
|
-
* Creates a coordinator backed by the host event registrar.
|
|
44
|
-
*
|
|
45
|
-
* @param registrar Host adapter used to install event listeners.
|
|
46
|
-
*/
|
|
47
|
-
constructor(registrar: EventRegistrar) {
|
|
48
|
-
this.registrar = registrar;
|
|
49
|
-
}
|
|
48
|
+
constructor(private readonly registrar: EventRegistrar) {}
|
|
50
49
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
*/
|
|
56
|
-
on(event: RuntimeEventName, handler: RuntimeEventHandler): () => void {
|
|
50
|
+
on<K extends RuntimeEventName>(
|
|
51
|
+
event: K,
|
|
52
|
+
handler: RuntimeEventHandler<K>,
|
|
53
|
+
): () => void {
|
|
57
54
|
const handlers = this.handlers.get(event) ?? new Set<RuntimeEventHandler>();
|
|
58
|
-
|
|
55
|
+
const registered: RuntimeEventHandler = (payload, ctx) =>
|
|
56
|
+
handler(payload as RuntimeEvent<K>, ctx);
|
|
57
|
+
handlers.add(registered);
|
|
59
58
|
this.handlers.set(event, handlers);
|
|
60
|
-
return () =>
|
|
59
|
+
return () => {
|
|
60
|
+
handlers.delete(registered);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 专用 hook 直接使用 Pi;共享生命周期统一经过本协调器。 */
|
|
65
|
+
coordinate(pi: ExtensionAPI): ExtensionAPI {
|
|
66
|
+
const on = ((event: string, handler: RuntimeEventHandler) => {
|
|
67
|
+
if ((RUNTIME_EVENTS as readonly string[]).includes(event)) {
|
|
68
|
+
return this.on(event as RuntimeEventName, handler);
|
|
69
|
+
}
|
|
70
|
+
return pi.on(event as never, handler as never);
|
|
71
|
+
}) as ExtensionAPI["on"];
|
|
72
|
+
return new Proxy(pi, {
|
|
73
|
+
get(target, property, receiver) {
|
|
74
|
+
return property === "on" ? on : Reflect.get(target, property, receiver);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
61
77
|
}
|
|
62
78
|
|
|
63
|
-
/**
|
|
64
|
-
* Installs one host listener per coordinated event.
|
|
65
|
-
*/
|
|
66
79
|
install(): void {
|
|
67
|
-
if (this.installed)
|
|
80
|
+
if (this.installed) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
68
83
|
this.installed = true;
|
|
69
84
|
for (const event of RUNTIME_EVENTS) {
|
|
70
|
-
this.registrar.on(event, (payload, ctx) =>
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
85
|
+
this.registrar.on(event, (payload, ctx) =>
|
|
86
|
+
this.dispatch(event, payload, ctx),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
dispatch<K extends RuntimeEventName>(
|
|
92
|
+
event: K,
|
|
93
|
+
payload: RuntimeEvent<K>,
|
|
94
|
+
ctx: ExtensionContext,
|
|
95
|
+
): void | MessageReplacement | Promise<void | MessageReplacement>;
|
|
96
|
+
dispatch(
|
|
97
|
+
event: RuntimeEventName,
|
|
98
|
+
payload: RuntimeEvent<RuntimeEventName>,
|
|
99
|
+
ctx: ExtensionContext,
|
|
100
|
+
): void | MessageReplacement | Promise<void | MessageReplacement> {
|
|
101
|
+
let replacement: MessageReplacement | undefined;
|
|
102
|
+
let pending: Promise<void> | undefined;
|
|
103
|
+
const accept = (result: void | MessageReplacement): void => {
|
|
104
|
+
if (!result) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (
|
|
108
|
+
payload.type !== "message_end" ||
|
|
109
|
+
result.message.role !== payload.message.role
|
|
110
|
+
) {
|
|
111
|
+
throw new TypeError(
|
|
112
|
+
"Only message_end may replace a message, with the same role",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
replacement = result;
|
|
116
|
+
payload.message = result.message;
|
|
117
|
+
};
|
|
118
|
+
for (const handler of [...(this.handlers.get(event) ?? [])]) {
|
|
119
|
+
if (pending) {
|
|
120
|
+
pending = pending.then(() => handler(payload, ctx)).then(accept);
|
|
121
|
+
} else {
|
|
122
|
+
const result = handler(payload, ctx);
|
|
123
|
+
if (result instanceof Promise) {
|
|
124
|
+
pending = result.then(accept);
|
|
125
|
+
} else {
|
|
126
|
+
accept(result);
|
|
79
127
|
}
|
|
80
|
-
|
|
81
|
-
});
|
|
128
|
+
}
|
|
82
129
|
}
|
|
130
|
+
return pending ? pending.then(() => replacement) : replacement;
|
|
83
131
|
}
|
|
84
132
|
}
|