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.
Files changed (49) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/README.en.md +19 -6
  3. package/README.md +19 -6
  4. package/extensions/app/config/context.ts +42 -0
  5. package/extensions/app/config/editor.ts +80 -0
  6. package/extensions/app/config/footer.ts +291 -0
  7. package/extensions/app/config/panel.ts +1 -5
  8. package/extensions/app/config/renderer.ts +1 -6
  9. package/extensions/app/config/shell.ts +358 -1435
  10. package/extensions/app/config/store.ts +22 -1
  11. package/extensions/app/config/values.ts +33 -0
  12. package/extensions/app/config/working-line.ts +100 -0
  13. package/extensions/app/overlay/input-router.ts +56 -32
  14. package/extensions/app/overlay/overlay-manager.ts +183 -21
  15. package/extensions/app/runtime/event-coordinator.ts +90 -42
  16. package/extensions/{features → app/runtime}/flush-docked-bash.ts +12 -2
  17. package/extensions/app/runtime/render-scheduler.ts +35 -31
  18. package/extensions/app/runtime/tui-runtime.ts +315 -312
  19. package/extensions/app/settings/controller.ts +202 -0
  20. package/extensions/app/settings/panel.ts +81 -0
  21. package/extensions/app/settings/presets.ts +43 -0
  22. package/extensions/features/context-inspector/index.ts +83 -662
  23. package/extensions/layouts/context/index.ts +58 -161
  24. package/extensions/layouts/context/message/user-message.ts +1 -1
  25. package/extensions/layouts/context/renderer/index.ts +31 -13
  26. package/extensions/layouts/context/renderer/mouse/interaction.ts +29 -14
  27. package/extensions/layouts/editor/controller.ts +9 -18
  28. package/extensions/layouts/editor/factory.ts +3 -3
  29. package/extensions/layouts/footer/controller.ts +30 -19
  30. package/extensions/layouts/footer/footer.ts +2 -2
  31. package/extensions/layouts/overlay/context-inspector.ts +497 -0
  32. package/extensions/{app → layouts}/overlay/selector-border.ts +5 -5
  33. package/extensions/{app → layouts}/overlay/selector-controller.ts +1 -1
  34. package/extensions/layouts/overlay/settings-panel.ts +359 -0
  35. package/extensions/{app/commands → layouts/overlay}/settings-previews.ts +4 -4
  36. package/extensions/layouts/working-line/controller.ts +3 -29
  37. package/extensions/layouts/working-line/working-line.ts +17 -114
  38. package/extensions/shared/working-line-text.ts +108 -0
  39. package/package.json +1 -1
  40. package/extensions/app/host/pi-extension-port.ts +0 -25
  41. package/extensions/app/host/pi-ui-port.ts +0 -52
  42. package/extensions/app/host/tui-capabilities.ts +0 -27
  43. package/extensions/app/ownership/layout-registry.ts +0 -65
  44. package/extensions/app/ownership.ts +0 -32
  45. package/extensions/app/panel.ts +0 -770
  46. package/extensions/app/presets.ts +0 -35
  47. package/extensions/app/runtime/runtime-state.ts +0 -83
  48. /package/extensions/{layouts/working-line → app/config}/working-line-messages.ts +0 -0
  49. /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
- for (const listener of this.listeners) listener(state.record);
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
- type RegisteredRoute = {
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: RegisteredRoute = {
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) this.routes.splice(index, 1);
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) return result;
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 activeCount = 0;
8
-
9
- /**
10
- * Runs one asynchronous overlay task and balances its active count.
11
- *
12
- * @param task Function that opens and waits for the overlay.
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
- this.activeCount += 1;
100
+ const token = {};
101
+ this.tasks.add(token);
17
102
  try {
18
103
  return await task();
19
104
  } finally {
20
- this.activeCount = Math.max(0, this.activeCount - 1);
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.activeCount > 0;
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.activeCount;
197
+ return this.tasks.size;
36
198
  }
37
199
  }
38
200
 
@@ -1,4 +1,9 @@
1
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
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
- export type RuntimeEventHandler = (
23
- event: unknown,
24
- ctx: ExtensionContext,
25
- ) => void | Promise<void>;
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
- * Registers a handler for one coordinated runtime event.
53
- *
54
- * @returns A function that removes the handler.
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
- handlers.add(handler);
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 () => handlers.delete(handler);
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) return;
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
- let pending: Promise<void> | undefined;
72
- for (const handler of this.handlers.get(event) ?? []) {
73
- if (pending) {
74
- pending = pending.then(() => handler(payload, ctx)).then(() => {});
75
- continue;
76
- }
77
- const result = handler(payload, ctx);
78
- if (result instanceof Promise) pending = result.then(() => {});
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
- return pending;
81
- });
128
+ }
82
129
  }
130
+ return pending ? pending.then(() => replacement) : replacement;
83
131
  }
84
132
  }